diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index d914c328fe1..fef62600a87 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -9,6 +9,9 @@ dist/ web-build/ expo-env.d.ts +# Signing / store credentials (personal fork) +credentials/ + # Native .kotlin/ *.orig.* diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4a0c761f2c6..9e8e29fc721 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -201,7 +201,7 @@ const config: ExpoConfig = { }, android: { icon: variant.assets.appIcon, - package: variant.androidPackage, + package: process.env.T3CODE_ANDROID_PACKAGE ?? variant.androidPackage, adaptiveIcon: { backgroundColor: variant.assets.androidAdaptiveBackgroundColor, foregroundImage: variant.assets.androidAdaptiveForeground, @@ -345,10 +345,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + projectId: process.env.T3CODE_EAS_PROJECT_ID ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454", }, }, - owner: "pingdotgg", + owner: process.env.T3CODE_EAS_OWNER ?? "pingdotgg", }; export default config; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index a1d315b7e14..8492627ed82 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -59,6 +59,19 @@ "android": { "track": "internal" } + }, + "personal": { + "ios": { + "ascAppId": "6793805091", + "appleTeamId": "C7X9BCC7LP", + "ascApiKeyPath": "./credentials/ios/AuthKey_5287YYUU8B.p8", + "ascApiKeyId": "5287YYUU8B", + "ascApiKeyIssuerId": "69a6de96-923d-47e3-e053-5b8c7c11a4d1" + }, + "android": { + "serviceAccountKeyPath": "./credentials/android/barbellry-45151cd5babe.json", + "track": "internal" + } } } } diff --git a/apps/mobile/src/features/terminal/TerminalLinkBar.tsx b/apps/mobile/src/features/terminal/TerminalLinkBar.tsx new file mode 100644 index 00000000000..b98d184e35a --- /dev/null +++ b/apps/mobile/src/features/terminal/TerminalLinkBar.tsx @@ -0,0 +1,73 @@ +import { memo, useMemo } from "react"; +import { Pressable, ScrollView, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { extractTerminalBufferLinks, terminalLinkLabel } from "./terminalBufferLinks"; +import type { TerminalTheme } from "./terminalTheme"; + +interface TerminalLinkBarProps { + readonly buffer: string; + readonly theme?: Pick; +} + +const DEFAULT_THEME = { + border: "rgba(255, 255, 255, 0.1)", + foreground: "#e5e5e5", + mutedForeground: "#a3a3a3", +}; + +/** + * Tappable chips for URLs printed in the terminal. The Ghostty surface draws + * text on the GPU without link hit-testing, so this is how terminal URLs + * (e.g. the `claude` OAuth login URL) are opened on mobile. Tap opens the + * full reassembled URL; long-press copies it. + */ +export const TerminalLinkBar = memo(function TerminalLinkBar(props: TerminalLinkBarProps) { + const links = useMemo(() => extractTerminalBufferLinks(props.buffer), [props.buffer]); + const theme = props.theme ?? DEFAULT_THEME; + + if (links.length === 0) { + return null; + } + + return ( + + + {links.map((url) => ( + ({ + flexDirection: "row", + alignItems: "center", + gap: 6, + borderRadius: 999, + borderWidth: 1, + borderColor: theme.border, + paddingHorizontal: 12, + paddingVertical: 6, + opacity: pressed ? 0.6 : 1, + })} + onPress={() => void tryOpenExternalUrl(url, "terminal-link")} + onLongPress={() => copyTextWithHaptic(url, { target: "terminal link" })} + > + + + {terminalLinkLabel(url)} + + + ))} + + + ); +}); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 1c7adb6834d..1b368596c13 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -8,6 +8,7 @@ import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAttachedTerminalSession } from "../../state/use-terminal-session"; import { TerminalSurface } from "./NativeTerminalSurface"; +import { TerminalLinkBar } from "./TerminalLinkBar"; import { hasNativeTerminalSurface } from "./nativeTerminalModule"; import { buildThreadTerminalAttachInput, @@ -238,6 +239,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( + ) : ( <> + { + it("finds a plain URL on its own line", () => { + const buffer = + "Browser didn't open? Use the url below to sign in:\n\nhttps://example.com/auth\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth"]); + }); + + it("reassembles a URL hard-wrapped to the terminal width", () => { + const width = 48; + const buffer = `Use the url below to sign in:\n\n${wrapToWidth(LOGIN_URL, width)}\n\nPaste code here if prompted:\n`; + expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]); + }); + + it("does not glue following prose onto a URL that merely ends a short line", () => { + const buffer = "Docs: https://example.com/docs\nthanks for reading\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/docs"]); + }); + + it("ignores ANSI styling wrapped around and inside the URL", () => { + const buffer = + "\x1b[2J\x1b[H\x1b[1;34mSign in:\x1b[0m \x1b[4mhttps://example.com/\x1b[36mauth?state=abc\x1b[0m\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth?state=abc"]); + }); + + it("reassembles a wrapped URL whose rows are painted with cursor-positioning sequences", () => { + const width = 40; + const rows = wrapToWidth(LOGIN_URL, width).split("\n"); + const padded = `Open the link below to continue login now`; + const buffer = rows + .map((row, index) => `\x1b[${index + 3};1H\x1b[K${row}`) + .join("\r\n") + .concat(`\r\n${padded}\r\n`); + expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]); + }); + + it("extracts OSC 8 hyperlink targets exactly", () => { + const buffer = + "See \x1b]8;;https://example.com/very/long/target\x1b\\the docs\x1b]8;;\x1b\\ page\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/very/long/target"]); + }); + + it("trims trailing punctuation and unbalanced closers", () => { + const buffer = "Read https://example.com/a). Then https://example.com/b,\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual([ + "https://example.com/b", + "https://example.com/a", + ]); + }); + + it("drops spinner-truncated prefixes of the same URL, keeping most recent first", () => { + const buffer = + "https://example.com/oauth?state=ab\rhttps://example.com/oauth?state=abcdef\nDone. https://example.com/next\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual([ + "https://example.com/next", + "https://example.com/oauth?state=abcdef", + ]); + }); + + it("caps results and prefers the most recent URLs", () => { + const buffer = Array.from({ length: 6 }, (_, index) => `https://example.com/${index}`).join( + "\n", + ); + const links = extractTerminalBufferLinks(buffer); + expect(links).toHaveLength(TERMINAL_LINK_MAX_RESULTS); + expect(links[0]).toBe("https://example.com/5"); + }); + + it("ignores a URL cut in half by the scan window", () => { + const filler = `${"x".repeat(200)}\n`; + const buffer = `https://truncated.example.com/${"a".repeat(TERMINAL_LINK_SCAN_WINDOW_BYTES)}\n${filler.repeat(4)}https://kept.example.com/ok\n`; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://kept.example.com/ok"]); + }); + + it("returns nothing for an empty or URL-free buffer", () => { + expect(extractTerminalBufferLinks("")).toEqual([]); + expect(extractTerminalBufferLinks("$ ls\nsrc package.json\n")).toEqual([]); + }); +}); + +describe("terminalLinkLabel", () => { + it("shows only the host for bare origins", () => { + expect(terminalLinkLabel("https://claude.ai")).toBe("claude.ai"); + expect(terminalLinkLabel("https://claude.ai/")).toBe("claude.ai"); + }); + + it("marks URLs that carry a path or query", () => { + expect(terminalLinkLabel(LOGIN_URL)).toBe("claude.ai/…"); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalBufferLinks.ts b/apps/mobile/src/features/terminal/terminalBufferLinks.ts new file mode 100644 index 00000000000..45cd2ab99c6 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalBufferLinks.ts @@ -0,0 +1,167 @@ +/** + * Extracts http(s) URLs from the raw PTY byte stream so the terminal panel can + * offer them as tappable links. The native Ghostty surface renders text on the + * GPU with no tap-to-open support, so links are surfaced in a chip bar instead. + * + * Working on the logical stream (rather than the rendered grid) also lets us + * recover URLs that TUIs hard-wrap to the terminal width — critical on phones + * where OAuth login URLs span many rows. + */ + +/** Only the tail of the buffer is scanned; login/auth URLs are always recent. */ +export const TERMINAL_LINK_SCAN_WINDOW_BYTES = 16_384; + +export const TERMINAL_LINK_MAX_RESULTS = 3; + +/* eslint-disable no-control-regex -- ANSI escape parsing matches ESC/BEL bytes by design */ +const OSC_HYPERLINK_PATTERN = /\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)/g; +const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g; +const CSI_PATTERN = /\x1b\[[0-9;:?]*[ -/]*[@-~]/g; +const ESC_CHARSET_PATTERN = /\x1b[()*+][0-9A-Za-z]/g; +const ESC_SINGLE_PATTERN = /\x1b[@-Z\\-_]/g; +// Everything below 0x20 except \n, plus DEL. \r and \r\n are normalized to \n +// beforehand. +const CONTROL_CHAR_PATTERN = /[\x00-\x09\x0b-\x1f\x7f]/g; +/* eslint-enable no-control-regex */ + +const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; +const URL_CONTINUATION_PATTERN = /^[^\s"'`<>]+/; +const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; + +/** Mirrors the web terminal's link cleanup (apps/web/src/terminal-links.ts). */ +function trimClosingDelimiters(value: string): string { + let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); + + const trimUnbalanced = (open: string, close: string) => { + while (output.endsWith(close)) { + const opens = output.split(open).length - 1; + const closes = output.split(close).length - 1; + if (opens >= closes) return; + output = output.slice(0, -1); + } + }; + + trimUnbalanced("(", ")"); + trimUnbalanced("[", "]"); + trimUnbalanced("{", "}"); + return output; +} + +function extractOscHyperlinks(raw: string): string[] { + const urls: string[] = []; + OSC_HYPERLINK_PATTERN.lastIndex = 0; + for (const match of raw.matchAll(OSC_HYPERLINK_PATTERN)) { + const uri = match[1]; + if (uri !== undefined && /^https?:\/\//.test(uri)) { + urls.push(uri); + } + } + return urls; +} + +function stripAnsi(raw: string): string { + return raw + .replace(OSC_PATTERN, "") + .replace(CSI_PATTERN, "") + .replace(ESC_CHARSET_PATTERN, "") + .replace(ESC_SINGLE_PATTERN, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(CONTROL_CHAR_PATTERN, ""); +} + +/** + * TUIs (e.g. Ink) wrap output to the terminal width themselves, so a URL cut + * by wrapping fills its whole row and continues as a bare token on the next + * line. Rows narrower than this are treated as intentional line ends — phone + * terminals report ~45+ columns at the default font size, and requiring it + * avoids gluing following prose onto short URLs that merely end a line. + */ +const MIN_WRAP_JOIN_WIDTH = 40; + +function joinWrappedUrl( + lines: ReadonlyArray, + lineIndex: number, + matchText: string, + matchEnd: number, +): string { + let url = matchText; + let index = lineIndex; + let cutByWrap = matchEnd === lines[index]?.length; + + while (cutByWrap && (lines[index]?.length ?? 0) >= MIN_WRAP_JOIN_WIDTH) { + const nextLine = lines[index + 1]; + if (nextLine === undefined) break; + const continuation = URL_CONTINUATION_PATTERN.exec(nextLine)?.[0]; + // A wrap continuation is the line's only content; anything after a space + // is prose that happens to follow the URL. + if (continuation === undefined || continuation !== nextLine.trimEnd()) break; + + url += continuation; + index += 1; + cutByWrap = continuation.length === nextLine.length; + } + + return url; +} + +/** + * Returns unique http(s) URLs found near the end of the terminal buffer, most + * recent first. Wrapped URLs are reassembled and ANSI styling is ignored. + */ +export function extractTerminalBufferLinks(buffer: string): string[] { + if (buffer.length === 0) return []; + + let window = buffer.slice(-TERMINAL_LINK_SCAN_WINDOW_BYTES); + if (window.length < buffer.length) { + // Drop the possibly mid-sequence/mid-URL first line of the window. + const firstNewline = window.indexOf("\n"); + if (firstNewline >= 0) { + window = window.slice(firstNewline + 1); + } + } + + const oscUrls = extractOscHyperlinks(window); + const text = stripAnsi(window); + const lines = text.split("\n"); + + const ordered: string[] = [...oscUrls]; + for (const [lineIndex, line] of lines.entries()) { + URL_PATTERN.lastIndex = 0; + for (const match of line.matchAll(URL_PATTERN)) { + const start = match.index ?? -1; + if (start < 0) continue; + const joined = joinWrappedUrl(lines, lineIndex, match[0], start + match[0].length); + const trimmed = trimClosingDelimiters(joined); + if (trimmed.length > "https://".length) { + ordered.push(trimmed); + } + } + } + + // Keep the last occurrence of each URL, and drop URLs that are prefixes of a + // longer one (spinner \r-redraws leave truncated duplicates behind). + const deduped: string[] = []; + for (let index = ordered.length - 1; index >= 0; index -= 1) { + const url = ordered[index]; + if (url === undefined) continue; + if (deduped.some((existing) => existing.startsWith(url))) continue; + deduped.push(url); + } + + return deduped.slice(0, TERMINAL_LINK_MAX_RESULTS); +} + +/** Compact chip label: host plus a hint that a path/query follows. */ +export function terminalLinkLabel(url: string): string { + try { + const parsed = new URL(url); + const hasTail = + (parsed.pathname !== "/" && parsed.pathname !== "") || + parsed.search !== "" || + parsed.hash !== ""; + return hasTail ? `${parsed.host}/…` : parsed.host; + } catch { + return url.length > 40 ? `${url.slice(0, 39)}…` : url; + } +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 37a8639fdbd..5d46a008210 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -949,6 +949,7 @@ function renderFeedEntry( /> ) : ( Ported 2026-07-25 from the (archived) planning repo [r4iju/t3-code](https://github.com/r4iju/t3-code); +> this copy is now the living runbook. Historical decision links below point at that repo's issues. + +Spec for [issue #6](https://github.com/r4iju/t3-code/issues/6). Decisions settled on the +[wayfinder map](https://github.com/r4iju/t3-code/issues/1) on 2026-07-23: v1 is upstream's +`apps/mobile` as-is (#5), fork delta stays env/config-only with ad-hoc `upstream/main` merges (#8), +distribution is personal/internal only, connectivity is direct LAN only. + +## Principles + +- **The fork ships upstream's app; we ship the pipeline.** No feature delta, so the pipeline is the + product of this effort. +- **Env/config-only delta.** Pipeline config lives in EAS environment variables, additive files, and + this repo's docs — upstream files are edited only when unavoidable (e.g. if `app.config.ts` lacks + an override hook for bundle identity). +- **decent-measure is the style reference, not a template to copy.** Where its choices fight the + env/config-only rule (local credentials + fastlane), we deviate deliberately. + +## Identity + +- Own bundle/application ID per variant, own EAS project, own App Store Connect record — upstream's + `com.t3tools.t3code.*` IDs stay untouched for clean upstream merges: + - iOS/Android production: `com.raijustudios.t3code` + - Development variant keeps upstream's dev ID locally (dev builds are never distributed). +- Apple team `C7X9BCC7LP` (same as decent-measure). App name on TestFlight: "T3 Code (personal)". +- Prefer setting identity via EAS env vars / `APP_VARIANT` hooks if upstream's `app.config.ts` + supports it; otherwise a minimal, well-marked edit in `app.config.ts` (allowed "when unavoidable"). + +## Build & distribute + +| Platform | Profile | Output | Distribution | +| -------- | --------------------- | ------ | ----------------------------------------------- | +| iOS | upstream `production` | .ipa | `eas submit` → **TestFlight internal** | +| Android | upstream `production` | .aab | `eas submit` → **Google Play internal testing** | + +> **Decision change (2026-07-23):** Android originally shipped as a preview APK via EAS internal +> link. Changed to match decent-measure: production AAB submitted to the Play internal testing +> track with the same Play service account (`barbellry-…json`). Requires a Play Console app record +> for `com.raijustudios.t3code` (manual — Play has no app-creation API) and the fork's +> `T3CODE_ANDROID_PACKAGE` env hook (upstream has no Android identity override). + +- **EAS cloud is the default builder**; the free tier covers occasional personal builds. + Local `eas build --local` is the documented fallback (needs Xcode 26.1+ — ticket #9 — and + JDK 17 + `ANDROID_HOME`; see friction log on + [issue #3](https://github.com/r4iju/t3-code/issues/3)). +- **Credentials: EAS-managed (remote)** for both platforms. This deviates from decent-measure's + local-credentials + fastlane setup on purpose — remote credentials mean zero credential files in + the fork, consistent with env/config-only. Submit credentials (ASC API key for iOS, Play service + account key for Android, both shared with decent-measure) live in the gitignored + `apps/mobile/credentials/` and are referenced by the `personal` submit profile — nothing secret + is committed. +- **Versioning:** follow upstream's app version (their `appVersion` runtime policy); build numbers + auto-increment via EAS remote version source. +- **T3 Connect env vars stay unset.** LAN-only scope; cloud UI stays disabled in our builds. + +## Release ritual + +```bash +cd ~/code/t3code +git fetch upstream && git merge upstream/main # ad-hoc sync (#8) +cd apps/mobile +# T3CODE_EAS_OWNER / T3CODE_EAS_PROJECT_ID / T3CODE_ANDROID_PACKAGE env vars required locally +vp run eas:ios:prod # → .ipa +vp run eas:android:prod # → .aab +eas submit -p ios --latest --profile personal # → TestFlight internal +eas submit -p android --latest --profile personal # → Play internal testing +``` + +No CI initially — releases are manual and occasional. If cadence grows, lift upstream's +fingerprint-based EAS workflow (`.github/workflows/mobile-eas-*.yml`) into the fork. + +## Server operations (decided with the map) + +The LAN server is the **desktop app with Settings → Connections → Network access toggled on** +(binds `0.0.0.0:3773`, stable port, built-in pairing QR). No launchd service, no headless `t3 serve` +for daily use. Device pairing management: `t3 auth` (`pairing create`, `session list/revoke`). +Pairing tokens expire in ~5 minutes — mint right before pairing a new device. + +## Implementation checklist (post-map execution) + +1. Create the App Store Connect app record for `com.raijustudios.t3code`. +2. `eas init` in the fork's `apps/mobile` against a personal EAS project; set EAS env vars/secrets + (ASC API key; identity overrides if the env hook exists). +3. First iOS `production` build + submit; install from TestFlight. +4. Create the Play Console app record for `com.raijustudios.t3code` (manual); first Android + `production` AAB build + submit to the internal testing track; install from Play. +5. Pair both against the desktop app's network endpoint (`http://:3773`). +6. Verify local-build fallback once Xcode 26.1+ lands (#9). diff --git a/docs/personal/research-server-protocol-and-direct-lan-auth.md b/docs/personal/research-server-protocol-and-direct-lan-auth.md new file mode 100644 index 00000000000..c1a44282960 --- /dev/null +++ b/docs/personal/research-server-protocol-and-direct-lan-auth.md @@ -0,0 +1,114 @@ +# t3code server protocol & direct-LAN auth + +Research for [issue #2](https://github.com/r4iju/t3-code/issues/2). Read against upstream +[pingdotgg/t3code](https://github.com/pingdotgg/t3code) @ main, 2026-07-23 (shallow clone at +`~/code/t3code-upstream`). File references below are paths in that repo. + +## TL;DR + +- **Transport:** one WebSocket at `/ws` carrying **Effect RPC with JSON serialization** (not tRPC, + not REST-per-call, not SSE). A small plain-HTTP surface handles auth, assets, and the SPA. + Streaming (agent output, terminals, VCS status) is RPC streams multiplexed on that same socket. +- **Direct LAN is first-class and relay-free.** `t3 serve --host ` (or the desktop app's + _Settings → Connections → Network access_ toggle) binds beyond loopback and prints/shows a + one-time pairing token + QR. Clerk / T3 Connect is an optional NAT-traversal add-on, disabled in + a fresh clone. +- **The official mobile app already supports exactly our target flow:** an "Add Environment" + screen with Host + pairing-code fields (and QR scan) that exchanges the token for a bearer + session and connects directly over `ws://:3773/ws` — no cloud account, no relay. +- **Protocol reuse outside the monorepo is possible but heavy:** the protocol packages are clean + of platform coupling but private, unbuilt, workspace-only, and pinned to an Effect 4.0 _beta_. + A greenfield client means vendoring three packages and reimplementing the platform seam the + upstream mobile app already implements. + +## Protocol map + +**Primary transport** — `GET /ws` upgrades to a WebSocket serving the `WsRpcGroup` RPC group from +`@t3tools/contracts` via `RpcServer.toHttpEffectWebsocket` with `RpcSerialization.layerJson` +(`apps/server/src/ws.ts:2086-2150`). The per-method authorization-scope map at `ws.ts:288-359` is +effectively the API catalog: `dispatchCommand`, `getTurnDiff`, `subscribeShell`, `subscribeThread`, +`terminal*`, `preview*`, `vcs*`, `git*`, `projects*`, and the `subscribe*` stream family. Agent +output arrives as `thread.*` orchestration events through `subscribeShell`/`subscribeThread` RPC +streams, coalesced server-side (`ws.ts:793-838`). + +**Auxiliary HTTP** (`apps/server/src/server.ts:350-369`): the `EnvironmentHttpApi` (Effect HttpApi; +groups `metadata`, `auth`, `orchestration`, `connect` — defined in +`packages/contracts/src/environmentHttp.ts`), an OTLP trace proxy, asset routes, an MCP HTTP +server, and a static fallback serving the bundled web client. + +**Listen address** — default port **3773** (`apps/server/src/config.ts:17`), binding falls back to +`127.0.0.1` unless `--host`/`T3CODE_HOST` is set (`apps/server/src/server.ts:129-144`). The desktop +app's Network-access toggle rebinds to `0.0.0.0` and advertises the LAN IPv4 +(`apps/desktop/src/backend/DesktopServerExposure.ts:30-134`). + +**CORS** — packaged builds send `access-control-allow-origin: *` (auth is the gate, not CORS; +`apps/server/src/http.ts:45-60`). A native app is outside CORS anyway: it needs only reachability +plus a valid credential. + +## Auth for a direct connection + +There is **no anonymous path, even on loopback** — every request and WS upgrade authenticates +against a scoped session (`apps/server/src/auth/EnvironmentAuth.ts:591,936`). The bootstrap chain: + +1. Server issues a **one-time pairing token**. Headless: `t3 serve` prints connection string, + token, pairing URL (`http://:/pair#token=...`) and a QR code + (`apps/server/src/startupAccess.ts:122-148`). Desktop seeds an unbounded bootstrap token for + its own renderer. +2. Client exchanges it at **`POST /oauth/token`** (RFC 8693 token-exchange shape) for an opaque + **bearer session token** (`EnvironmentAuth.ts:690`). +3. Bearer is traded at **`POST /api/auth/websocket-ticket`** for a short-lived single-use ticket, + passed as `/ws?wsTicket=...` so tokens stay out of URLs. + +Sessions persist (SQLite + chmod-600 secrets under the server state dir); `t3 auth` issues extra +pairing credentials and lists/revokes sessions. Ordinary pairing grants scopes +`orchestration:read/operate terminal:operate review:write relay:read`. + +**The Clerk / T3 Connect relay is optional and off by default** ("disabled in a fresh clone" — +`docs/cloud/t3-connect-clerk.md`). It adds a hosted cloudflared tunnel and a separate credential +system (Clerk JWTs + DPoP); it is irrelevant to our LAN-only scope. `packages/tailscale` and +`packages/ssh` are likewise optional access-method providers that converge on the same WS + +pairing model. + +## The mobile direct-LAN flow (already implemented upstream) + +`apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx` is an "Add Environment" screen +with a Host field (placeholder `192.168.1.100:8080`), a pairing-code field, and a QR scanner. It +builds a pairing URL, runs `preparePairingRegistration` +(`packages/client-runtime/src/connection/onboarding.ts:86-119`), and registers a relay-free +`BearerConnectionTarget` (`packages/client-runtime/src/connection/model.ts:18-46`). + +Recipe: `t3 serve --host ` → scan the printed QR from the phone (or type +`http://:3773` + code). One caveat: a bare `host:port` without scheme is normalized to +**`https://`** (`packages/shared/src/remote.ts:85-87`), so plain-HTTP LAN servers need the +explicit `http://` prefix — or the QR, which carries it. + +## Reusability of the protocol packages + +- `@t3tools/contracts` (Effect Schema + Effect RPC/HttpApi contract definitions) and + `@t3tools/client-runtime` (connection lifecycle, RPC session, ~55 files of client state reducers + exposed as Effect Atoms) are **platform-clean**: no React/DOM/Node/Expo imports; WebSocket and + fetch are injected. React binding comes from `@effect/atom-react` in the consuming app. +- But they are **private, versionless (client-runtime), unbuilt (exports point at raw `.ts`), + workspace-only (`workspace:*` deps incl. `@t3tools/shared`), unpublished**, and the whole stack + is pinned to **Effect `4.0.0-beta.78`** using `effect/unstable/*` APIs that can break between + betas. +- A consumer must implement the `platform/` seam (capability + persistence + connection-source + Layers) — upstream mobile does this with `expo-network`, RN `AppState`, `expo-sqlite`, + `expo-secure-store` (`apps/mobile/src/connection/platform.ts`, `src/persistence/`, + `src/lib/runtime.ts`). +- `apps/mobile` is mostly UI on top of that layer, plus **five local Expo native modules** + (Swift/Kotlin): a libghostty-based terminal surface, native diff renderer, composer editor, + native controls, and selectable markdown text. These are the hardest parts to port and the main + reason a from-scratch client is expensive. + +## Implications for the fork-vs-greenfield decision (issue #4) + +- Greenfield ≠ "just call an HTTP API": the protocol is Effect RPC over WS with contracts living + in unpublished beta-pinned packages. A greenfield app either vendors + `contracts`+`client-runtime`+`shared` and adopts Effect wholesale, or reimplements the wire + protocol by hand against a moving target. +- Building inside a fork keeps `workspace:*` resolution, the working platform-seam glue, and the + native modules for free; the decent-measure influence would then be limited to release/tooling + style rather than repo structure. +- Either way, connectivity/auth is a non-issue for our scope: the direct-LAN pairing flow exists, + is relay-free, and is the documented recommended path. diff --git a/patches/react-native-nitro-markdown@0.5.8.patch b/patches/react-native-nitro-markdown@0.5.8.patch new file mode 100644 index 00000000000..d0f15a69952 --- /dev/null +++ b/patches/react-native-nitro-markdown@0.5.8.patch @@ -0,0 +1,317 @@ +diff --git a/lib/commonjs/markdown.js b/lib/commonjs/markdown.js +index a489ad48db790aa997d15057f8c54033063dbcf1..b9ee414d371c2fc4ed0d3fcfbce255258de57f1b 100644 +--- a/lib/commonjs/markdown.js ++++ b/lib/commonjs/markdown.js +@@ -188,7 +188,8 @@ const Markdown = ({ + virtualizationMinBlocks = 40, + virtualization, + tableOptions, +- highlightCode ++ highlightCode, ++ selectable = false + }) => { + const parserOptionGfm = options?.gfm; + const parserOptionMath = options?.math; +@@ -255,8 +256,9 @@ const Markdown = ({ + stylingStrategy, + onLinkPress, + tableOptions, +- highlightCode +- }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode]); ++ highlightCode, ++ selectable ++ }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode, selectable]); + const topLevelBlocks = parseResult.ast?.type === "document" ? parseResult.ast.children ?? [] : parseResult.ast ? [parseResult.ast] : []; + const shouldVirtualizeBySetting = virtualize === true || virtualize === "auto" && topLevelBlocks.length >= virtualizationMinBlocks; + const shouldVirtualize = parseResult.ast !== null && shouldVirtualizeBySetting; +@@ -317,7 +319,8 @@ const NodeRendererComponent = ({ + const { + renderers, + theme, +- styles: nodeStyles ++ styles: nodeStyles, ++ selectable + } = (0, _MarkdownContext.useMarkdownContext)(); + const baseStyles = getBaseStyles(theme); + const renderChildren = (children, childInListItem = false, childParentIsText = false) => { +@@ -345,7 +348,8 @@ const NodeRendererComponent = ({ + } else { + const Wrapper = childParentIsText ? _react.Fragment : _reactNative.Text; + const wrapperProps = childParentIsText ? {} : { +- style: baseStyles.text ++ style: baseStyles.text, ++ selectable + }; + elements.push(/*#__PURE__*/(0, _jsxRuntime.jsx)(Wrapper, { + ...wrapperProps, +@@ -443,6 +447,7 @@ const NodeRendererComponent = ({ + }); + } + return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { ++ selectable, + style: [baseStyles.text, nodeStyles?.text], + children: node.content + }); +diff --git a/lib/commonjs/renderers/heading.js b/lib/commonjs/renderers/heading.js +index 7e56d037a9620a78c77bc79894d578b1d81740be..f2b136d1e37e0e967322be44561ae2d13d016d39 100644 +--- a/lib/commonjs/renderers/heading.js ++++ b/lib/commonjs/renderers/heading.js +@@ -15,11 +15,13 @@ const Heading = ({ + style + }) => { + const { +- theme ++ theme, ++ selectable + } = (0, _MarkdownContext.useMarkdownContext)(); + const styles = (0, _styleCache.getCachedStyles)(stylesCache, theme, createStyles); + const headingStyles = [styles.heading, level === 1 && styles.h1, level === 2 && styles.h2, level === 3 && styles.h3, level === 4 && styles.h4, level === 5 && styles.h5, level === 6 && styles.h6]; + return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { ++ selectable, + style: [...headingStyles, style], + children: children + }); +diff --git a/lib/module/markdown.js b/lib/module/markdown.js +index c97a209c1f8c01f8d6ba17e0f77ea174b82d1d7e..fc8798ff28984fc27733a8c9b955432881044c41 100644 +--- a/lib/module/markdown.js ++++ b/lib/module/markdown.js +@@ -184,7 +184,8 @@ export const Markdown = ({ + virtualizationMinBlocks = 40, + virtualization, + tableOptions, +- highlightCode ++ highlightCode, ++ selectable = false + }) => { + const parserOptionGfm = options?.gfm; + const parserOptionMath = options?.math; +@@ -251,8 +252,9 @@ export const Markdown = ({ + stylingStrategy, + onLinkPress, + tableOptions, +- highlightCode +- }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode]); ++ highlightCode, ++ selectable ++ }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode, selectable]); + const topLevelBlocks = parseResult.ast?.type === "document" ? parseResult.ast.children ?? [] : parseResult.ast ? [parseResult.ast] : []; + const shouldVirtualizeBySetting = virtualize === true || virtualize === "auto" && topLevelBlocks.length >= virtualizationMinBlocks; + const shouldVirtualize = parseResult.ast !== null && shouldVirtualizeBySetting; +@@ -312,7 +314,8 @@ const NodeRendererComponent = ({ + const { + renderers, + theme, +- styles: nodeStyles ++ styles: nodeStyles, ++ selectable + } = useMarkdownContext(); + const baseStyles = getBaseStyles(theme); + const renderChildren = (children, childInListItem = false, childParentIsText = false) => { +@@ -340,7 +343,8 @@ const NodeRendererComponent = ({ + } else { + const Wrapper = childParentIsText ? Fragment : Text; + const wrapperProps = childParentIsText ? {} : { +- style: baseStyles.text ++ style: baseStyles.text, ++ selectable + }; + elements.push(/*#__PURE__*/_jsx(Wrapper, { + ...wrapperProps, +@@ -438,6 +442,7 @@ const NodeRendererComponent = ({ + }); + } + return /*#__PURE__*/_jsx(Text, { ++ selectable, + style: [baseStyles.text, nodeStyles?.text], + children: node.content + }); +diff --git a/lib/module/renderers/heading.js b/lib/module/renderers/heading.js +index e8964caa5c663c196c0995f7a85177288854c10f..bcd10dba7b752a85548a8a75fb1d5db23e364a0c 100644 +--- a/lib/module/renderers/heading.js ++++ b/lib/module/renderers/heading.js +@@ -11,11 +11,13 @@ export const Heading = ({ + style + }) => { + const { +- theme ++ theme, ++ selectable + } = useMarkdownContext(); + const styles = getCachedStyles(stylesCache, theme, createStyles); + const headingStyles = [styles.heading, level === 1 && styles.h1, level === 2 && styles.h2, level === 3 && styles.h3, level === 4 && styles.h4, level === 5 && styles.h5, level === 6 && styles.h6]; + return /*#__PURE__*/_jsx(Text, { ++ selectable, + style: [...headingStyles, style], + children: children + }); +diff --git a/lib/typescript/commonjs/MarkdownContext.d.ts b/lib/typescript/commonjs/MarkdownContext.d.ts +index 79104e1b0f43f0d670a2a0669891dfa6d10bbd2a..9b9de6c1aea4fc96067e90491890e352e495e29d 100644 +--- a/lib/typescript/commonjs/MarkdownContext.d.ts ++++ b/lib/typescript/commonjs/MarkdownContext.d.ts +@@ -66,6 +66,7 @@ export type MarkdownContextValue = { + minColumnWidth?: number; + measurementStabilizeMs?: number; + }; ++ selectable?: boolean; + }; + export declare const MarkdownContext: import("react").Context; + export declare const useMarkdownContext: () => MarkdownContextValue; +diff --git a/lib/typescript/commonjs/markdown.d.ts b/lib/typescript/commonjs/markdown.d.ts +index 6d2a60212fbbce22ef94f49060186fec60e1b2a1..5153ed2e2c460a370e8fae20ac971f03ef437c1c 100644 +--- a/lib/typescript/commonjs/markdown.d.ts ++++ b/lib/typescript/commonjs/markdown.d.ts +@@ -147,6 +147,13 @@ export type MarkdownProps = { + * Pass `true` to use the built-in tokenizer, or a custom highlighter function. + */ + highlightCode?: boolean | CodeHighlighter; ++ /** ++ * Makes prose text selectable so users can copy fragments. ++ * Applied to the root Text nodes (inline groups, standalone text, headings), ++ * which is where the platform honors the prop. ++ * @default false ++ */ ++ selectable?: boolean; + }; + export declare const Markdown: FC; + //# sourceMappingURL=markdown.d.ts.map +\ No newline at end of file +diff --git a/lib/typescript/module/MarkdownContext.d.ts b/lib/typescript/module/MarkdownContext.d.ts +index 79104e1b0f43f0d670a2a0669891dfa6d10bbd2a..9b9de6c1aea4fc96067e90491890e352e495e29d 100644 +--- a/lib/typescript/module/MarkdownContext.d.ts ++++ b/lib/typescript/module/MarkdownContext.d.ts +@@ -66,6 +66,7 @@ export type MarkdownContextValue = { + minColumnWidth?: number; + measurementStabilizeMs?: number; + }; ++ selectable?: boolean; + }; + export declare const MarkdownContext: import("react").Context; + export declare const useMarkdownContext: () => MarkdownContextValue; +diff --git a/lib/typescript/module/markdown.d.ts b/lib/typescript/module/markdown.d.ts +index 6d2a60212fbbce22ef94f49060186fec60e1b2a1..5153ed2e2c460a370e8fae20ac971f03ef437c1c 100644 +--- a/lib/typescript/module/markdown.d.ts ++++ b/lib/typescript/module/markdown.d.ts +@@ -147,6 +147,13 @@ export type MarkdownProps = { + * Pass `true` to use the built-in tokenizer, or a custom highlighter function. + */ + highlightCode?: boolean | CodeHighlighter; ++ /** ++ * Makes prose text selectable so users can copy fragments. ++ * Applied to the root Text nodes (inline groups, standalone text, headings), ++ * which is where the platform honors the prop. ++ * @default false ++ */ ++ selectable?: boolean; + }; + export declare const Markdown: FC; + //# sourceMappingURL=markdown.d.ts.map +\ No newline at end of file +diff --git a/src/MarkdownContext.ts b/src/MarkdownContext.ts +index 2d93f0da14a8664724913d27e895e7f19f66a56c..6e72ce92da941185541ed7a773aa9de9c95f6191 100644 +--- a/src/MarkdownContext.ts ++++ b/src/MarkdownContext.ts +@@ -97,6 +97,7 @@ export type MarkdownContextValue = { + minColumnWidth?: number; + measurementStabilizeMs?: number; + }; ++ selectable?: boolean; + }; + + export const MarkdownContext = createContext({ +diff --git a/src/markdown.tsx b/src/markdown.tsx +index 50147a1c217f92353dc2f04b97a726d5289247ad..71205619e1b47d1f4a1f238fdcd8b6797bbadbea 100644 +--- a/src/markdown.tsx ++++ b/src/markdown.tsx +@@ -435,6 +435,13 @@ export type MarkdownProps = { + * Pass `true` to use the built-in tokenizer, or a custom highlighter function. + */ + highlightCode?: boolean | CodeHighlighter; ++ /** ++ * Makes prose text selectable so users can copy fragments. ++ * Applied to the root Text nodes (inline groups, standalone text, headings), ++ * which is where the platform honors the prop. ++ * @default false ++ */ ++ selectable?: boolean; + }; + + export const Markdown: FC = ({ +@@ -458,6 +465,7 @@ export const Markdown: FC = ({ + virtualization, + tableOptions, + highlightCode, ++ selectable = false, + }) => { + const parserOptionGfm = options?.gfm; + const parserOptionMath = options?.math; +@@ -564,6 +572,7 @@ export const Markdown: FC = ({ + onLinkPress, + tableOptions, + highlightCode, ++ selectable, + }), + [ + renderers, +@@ -573,6 +582,7 @@ export const Markdown: FC = ({ + onLinkPress, + tableOptions, + highlightCode, ++ selectable, + ], + ); + +@@ -658,7 +668,7 @@ const NodeRendererComponent: FC = ({ + inListItem, + parentIsText = false, + }) => { +- const { renderers, theme, styles: nodeStyles } = useMarkdownContext(); ++ const { renderers, theme, styles: nodeStyles, selectable } = useMarkdownContext(); + const baseStyles = getBaseStyles(theme); + + const renderChildren = ( +@@ -703,7 +713,7 @@ const NodeRendererComponent: FC = ({ + const Wrapper = childParentIsText ? Fragment : Text; + const wrapperProps = childParentIsText + ? {} +- : { style: baseStyles.text }; ++ : { style: baseStyles.text, selectable }; + + elements.push( + +@@ -814,7 +824,9 @@ const NodeRendererComponent: FC = ({ + return {node.content}; + } + return ( +- {node.content} ++ ++ {node.content} ++ + ); + + case "bold": +diff --git a/src/renderers/heading.tsx b/src/renderers/heading.tsx +index b388247dbd79554dc17f3eaab37616101589b95e..44785e8c4aef7079e64edddafdf1fa64187cf802 100644 +--- a/src/renderers/heading.tsx ++++ b/src/renderers/heading.tsx +@@ -21,7 +21,7 @@ const ANDROID_SYSTEM_FONTS = new Set([ + ]); + + export const Heading: FC = ({ level, children, style }) => { +- const { theme } = useMarkdownContext(); ++ const { theme, selectable } = useMarkdownContext(); + const styles = getCachedStyles(stylesCache, theme, createStyles); + + const headingStyles = [ +@@ -34,7 +34,11 @@ export const Heading: FC = ({ level, children, style }) => { + level === 6 && styles.h6, + ]; + +- return {children}; ++ return ( ++ ++ {children} ++ ++ ); + }; + + type HeadingStyles = ReturnType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..77f112d7824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,7 @@ patchedDependencies: expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + react-native-nitro-markdown@0.5.8: 9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 @@ -253,7 +254,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe) + version: file:apps/mobile/modules/t3-markdown-text(daaf54394c837c77cd399b3041ea2723) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -385,7 +386,7 @@ importers: version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(patch_hash=9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: 0.35.9 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14553,7 +14554,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(daaf54394c837c77cd399b3041ea2723)': dependencies: expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14561,7 +14562,7 @@ snapshots: expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-nitro-markdown: 0.5.8(patch_hash=9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -19703,7 +19704,7 @@ snapshots: react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(patch_hash=9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff840659efc..788f1efac1c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -117,6 +117,7 @@ patchedDependencies: expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch + react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch