diff --git a/.gitignore b/.gitignore index 7f5cb05..9219243 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ target/ dist/ dist-electron/ /release/ +/.artifacts/ .vite-plus/ *.tsbuildinfo /.worktrees/ diff --git a/README.md b/README.md index cb280f9..6fdc0a0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c T4 Code needs an OMP build with desktop appserver support. For v0.1.24, use the public integration build below. -T4 Code v0.1.24 was verified with OMP 17.0.4 built from [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), tagged [`t4code-17.0.4-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). That public integration is based on the official upstream [`v17.0.4`](https://github.com/can1357/oh-my-pi/tree/v17.0.4) tag at [`3fdd85ab`](https://github.com/can1357/oh-my-pi/commit/3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0). It adds redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.4 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.0 from integration commit [`ae4b53b4`](https://github.com/lyc-aon/oh-my-pi/commit/ae4b53b416f32b200865a32ed9baabd5a4666fa4), source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`. +T4 Code v0.1.24 was verified with OMP 17.0.5 built from [`3393ae0f`](https://github.com/lyc-aon/oh-my-pi/commit/3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f), tagged [`t4code-17.0.5-appserver-3`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-3). That public integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It adds faster appserver startup, cross-session attention and transcript search, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. This adds the bounded cross-session transcript search and historical context contract. diff --git a/apps/desktop/src/bundled-runtime.ts b/apps/desktop/src/bundled-runtime.ts new file mode 100644 index 0000000..59e546a --- /dev/null +++ b/apps/desktop/src/bundled-runtime.ts @@ -0,0 +1,67 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmod, copyFile, mkdir, readFile, rename, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; + +export interface BundledRuntimeManifest { + readonly version: 1; + readonly tag: string; + readonly platform: "darwin"; + readonly arch: "arm64"; + readonly executable: "omp"; + readonly size: number; + readonly sha256: string; +} + +function decodeManifest(value: unknown): BundledRuntimeManifest { + const record = value as Partial | null; + if ( + record?.version !== 1 || + record.platform !== "darwin" || + record.arch !== "arm64" || + record.executable !== "omp" || + typeof record.tag !== "string" || + !/^t4code-[0-9]+\.[0-9]+\.[0-9]+-appserver-[1-9][0-9]*$/u.test(record.tag) || + !Number.isSafeInteger(record.size) || + (record.size ?? 0) < 1 || + typeof record.sha256 !== "string" || + !/^[0-9a-f]{64}$/u.test(record.sha256) + ) throw new Error("bundled OMP runtime manifest is invalid"); + return record as BundledRuntimeManifest; +} + +async function matches(path: string, manifest: BundledRuntimeManifest): Promise { + try { + if ((await stat(path)).size !== manifest.size) return false; + const hash = createHash("sha256").update(await readFile(path)).digest("hex"); + return hash === manifest.sha256; + } catch { + return false; + } +} + +export async function installBundledOmpRuntime(options: { + readonly resourcesPath: string; + readonly applicationSupportPath: string; +}): Promise { + const sourceRoot = join(options.resourcesPath, "runtime"); + const manifest = decodeManifest(JSON.parse(await readFile(join(sourceRoot, "manifest.json"), "utf8"))); + const source = join(sourceRoot, manifest.executable); + if (!(await matches(source, manifest))) throw new Error("bundled OMP runtime failed its integrity check"); + const destinationRoot = join(options.applicationSupportPath, "runtime", manifest.tag); + const destination = join(destinationRoot, "omp"); + if (await matches(destination, manifest)) { + await chmod(destination, 0o755); + return destination; + } + await mkdir(destinationRoot, { recursive: true, mode: 0o700 }); + const temporary = join(destinationRoot, `.omp-${randomUUID()}.partial`); + try { + await copyFile(source, temporary); + await chmod(temporary, 0o755); + if (!(await matches(temporary, manifest))) throw new Error("installed OMP runtime failed its integrity check"); + await rename(temporary, destination); + } finally { + await unlink(temporary).catch(() => {}); + } + return destination; +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 5e2747e..2503f3b 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -7,6 +7,7 @@ import { decodeDesktopUpdateState, decodeProjectionCacheLoadResult, decodeProjectionCacheSaveResult, + decodePhoneSetupState, type BootstrapResult, type CommandRequest, type CommandResult, @@ -30,6 +31,7 @@ import { type PairLinkEvent, type PairRequest, type PairResult, + type PhoneSetupState, type PairLinksDrainResult, type ProjectionCacheLoadResult, type ProjectionCacheSaveRequest, @@ -81,6 +83,10 @@ export interface IpcRuntime { readonly load: () => ProjectionCacheLoadResult | Promise; readonly save: (value: string) => ProjectionCacheSaveResult | Promise; }; + readonly phoneSetup?: { + readonly inspect: () => Promise; + readonly configure: () => Promise; + }; } export class RemotePairingUnavailableError extends Error { readonly code = "remote_pairing_unavailable" as const; @@ -258,6 +264,16 @@ export class DesktopIpcRegistry { decodeRequest("app:update:get-state", payload); return decodeDesktopUpdateState(this.updateController().getState()); }); + this.ipc.handle("app:phone-setup:inspect", async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest("app:phone-setup:inspect", payload); + return decodePhoneSetupState(await this.phoneSetup().inspect()); + }); + this.ipc.handle("app:phone-setup:configure", async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest("app:phone-setup:configure", payload); + return decodePhoneSetupState(await this.phoneSetup().configure()); + }); this.ipc.handle("app:update:check", async (event, payload: unknown): Promise => { this.assertSender(event); decodeRequest("app:update:check", payload); @@ -296,6 +312,11 @@ export class DesktopIpcRegistry { this.emit("app:update:state", state); }); } + + private phoneSetup(): NonNullable { + if (!this.runtime.phoneSetup) throw new Error("Phone setup is unavailable in this build."); + return this.runtime.phoneSetup; + } uninstall(): void { this.updateUnsubscribe?.(); this.updateUnsubscribe = undefined; @@ -311,6 +332,7 @@ export class DesktopIpcRegistry { "app:update:get-state", "app:update:check", "app:update:download", "app:update:restart", "app:update:renderer-ready", "app:projection-cache:load", "app:projection-cache:save", + "app:phone-setup:inspect", "app:phone-setup:configure", ] as const) this.ipc.removeHandler(channel); this.installed = false; } diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index 5f7cb07..4485a6f 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -18,6 +18,8 @@ import { installApplicationMenu, type ApplicationMenuOptions } from "./menu.ts"; import { DesktopUpdateController } from "./update-controller.ts"; import { LocalProfileRegistry } from "./local-profiles.ts"; import { LocalProfileRuntime } from "./profile-runtime.ts"; +import { installBundledOmpRuntime } from "./bundled-runtime.ts"; +import { PhoneSetupService } from "./phone-setup.ts"; type ProjectionCacheRuntime = NonNullable; @@ -101,6 +103,7 @@ export class DesktopLifecycle { private readonly serviceRecoveryPromises = new Map>(); private readonly serviceAvailabilityIssues = new Map(); private updateController: DesktopUpdateController | undefined; + private phoneSetup: PhoneSetupService | undefined; private pendingUpdateOpen = false; private rendererLoaded = false; private updateRendererReady = false; @@ -127,7 +130,15 @@ export class DesktopLifecycle { () => new LocalProfileRegistry(new ElectronLocalProfileStore()) ); this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore()); - this.executableFactory = options.discoverExecutable ?? (() => discoverOmpExecutable()); + this.executableFactory = options.discoverExecutable ?? (async () => { + if (this.electronApp.isPackaged && process.platform === "darwin" && process.arch === "arm64") { + return installBundledOmpRuntime({ + resourcesPath: process.resourcesPath, + applicationSupportPath: this.electronApp.getPath("userData"), + }); + } + return discoverOmpExecutable(); + }); this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable)); this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager; this.targetManagerFactory = options.createTargetManager ?? ((managerOptions) => new LocalTargetManager(managerOptions)); @@ -174,13 +185,17 @@ export class DesktopLifecycle { this.projectionCache = this.projectionCacheFactory(); if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code"); this.updateController = this.updateControllerFactory(); + if (this.electronApp.isPackaged) { + this.phoneSetup = new PhoneSetupService({ + resourcesPath: process.resourcesPath, + electronExecutable: process.execPath, + }); + } this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() }); const identity = this.identityFactory(); const remoteRegistry = this.remoteRegistryFactory(); const credentials = this.credentialsFactory(); this.localProfileRegistry = this.localProfileRegistryFactory(); - await this.acquireServiceManager(); - if (this.stopping) return; this.manager = this.targetManagerFactory({ cursorStore: this.cursorStoreFactory(), registry: remoteRegistry, @@ -205,6 +220,8 @@ export class DesktopLifecycle { discoverExecutable: () => this.discoverServiceExecutable(), }); this.bindWindow(this.windowFactory()); + await this.acquireServiceManager(); + if (this.stopping) return; await this.profileRuntime.startAutomaticProfiles((profileId, error) => { this.ipc?.emitRuntimeError(runtimeError(error, `local:${profileId}`)); }); @@ -234,6 +251,7 @@ export class DesktopLifecycle { this.mainWindow = undefined; this.updateController?.dispose(); this.updateController = undefined; + this.phoneSetup = undefined; this.projectionCache = undefined; const manager = this.manager; this.manager = undefined; @@ -435,6 +453,7 @@ export class DesktopLifecycle { drainPairLinks: () => this.pendingPairs.drain(), drainPendingUpdateOpen: () => this.markUpdateRendererReady(), ...(this.updateController === undefined ? {} : { updateController: this.updateController }), + ...(this.phoneSetup === undefined ? {} : { phoneSetup: this.phoneSetup }), }); this.ipc.install(); handle.window.webContents.on("did-start-loading", () => { diff --git a/apps/desktop/src/phone-setup.ts b/apps/desktop/src/phone-setup.ts new file mode 100644 index 0000000..8bb44e1 --- /dev/null +++ b/apps/desktop/src/phone-setup.ts @@ -0,0 +1,178 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + buildTailscaleHttpsBaseUrl, + discoverTailscaleExecutable, + NodeProcessRunner, + readTailscaleStatus, + runProcess, + suggestTailscaleServe, + type ProcessRunner, +} from "@t4-code/remote"; +import type { PhoneSetupState } from "@t4-code/protocol/desktop-ipc"; + +const LOCAL_GATEWAY_PORT = 4_194; +const TAILSCALE_HTTPS_PORT = 8_445; + +export interface PhoneSetupServiceOptions { + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly resourcesPath: string; + readonly electronExecutable: string; + readonly runner?: ProcessRunner; + readonly discoverTailscale?: () => Promise; +} + +export class PhoneSetupService { + private readonly platform: NodeJS.Platform; + private readonly arch: string; + private readonly resourcesPath: string; + private readonly electronExecutable: string; + private readonly runner: ProcessRunner; + private readonly tailscaleExecutable: () => Promise; + private operation: Promise | undefined; + + constructor(options: PhoneSetupServiceOptions) { + this.platform = options.platform ?? process.platform; + this.arch = options.arch ?? process.arch; + this.resourcesPath = options.resourcesPath; + this.electronExecutable = options.electronExecutable; + this.runner = options.runner ?? new NodeProcessRunner(); + this.tailscaleExecutable = options.discoverTailscale ?? (() => discoverTailscaleExecutable({ platform: this.platform })); + } + + inspect(): Promise { + return this.inspectInternal(); + } + + configure(): Promise { + if (this.operation) return this.operation; + const operation = this.configureInternal().catch((error: unknown) => ({ + phase: "error" as const, + message: error instanceof Error ? error.message.slice(0, 512) : "Phone setup could not be completed.", + })); + this.operation = operation; + void operation.finally(() => { if (this.operation === operation) this.operation = undefined; }); + return operation; + } + + private unsupported(): PhoneSetupState | undefined { + if (this.platform !== "darwin" || this.arch !== "arm64") { + return { phase: "unsupported", message: "One-click phone setup currently requires the Apple Silicon Mac app." }; + } + return undefined; + } + + private async identity(): Promise { + const manifest = await readFile(join(this.resourcesPath, "runtime", "manifest.json")); + return `sha256:${createHash("sha256").update(manifest).digest("hex")}`; + } + + private async tailscaleFacts(): Promise<{ executable: string; url: string }> { + const executable = await this.tailscaleExecutable(); + const status = await readTailscaleStatus({ runner: this.runner, executable, timeoutMs: 3_000 }); + if (!status.magicDnsName) throw new Error("Tailscale is not connected or MagicDNS is unavailable."); + return { executable, url: buildTailscaleHttpsBaseUrl({ magicDnsName: status.magicDnsName, servePort: TAILSCALE_HTTPS_PORT }) }; + } + + private async runGatewayService(args: readonly string[]): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + return runProcess({ + runner: this.runner, + command: this.electronExecutable, + args: [join(this.resourcesPath, "gateway", "tailnet-service.mjs"), ...args], + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", ELECTRON_RUN_AS_NODE: "1" }, + timeoutMs: 20_000, + }); + } + + private async hasExpectedServe(executable: string, url: string): Promise { + try { + const result = await runProcess({ + runner: this.runner, + command: executable, + args: ["serve", "status", "--json"], + timeoutMs: 3_000, + }); + if (result.exitCode !== 0 || result.stderr.trim().length > 0) return false; + const parsed: unknown = JSON.parse(result.stdout); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const record = parsed as Record; + const tcp = record.TCP; + const web = record.Web; + if (tcp === null || typeof tcp !== "object" || Array.isArray(tcp)) return false; + if (web === null || typeof web !== "object" || Array.isArray(web)) return false; + const destination = new URL(url); + const port = destination.port || "443"; + const tcpPort = (tcp as Record)[port]; + if (tcpPort === null || typeof tcpPort !== "object" || Array.isArray(tcpPort)) return false; + if ((tcpPort as Record).HTTPS !== true) return false; + const authority = destination.port ? `${destination.hostname}:${destination.port}` : destination.hostname; + const webRoute = (web as Record)[authority]; + if (webRoute === null || typeof webRoute !== "object" || Array.isArray(webRoute)) return false; + const handlers = (webRoute as Record).Handlers; + if (handlers === null || typeof handlers !== "object" || Array.isArray(handlers)) return false; + const root = (handlers as Record)["/"]; + return root !== null && typeof root === "object" && !Array.isArray(root) + && (root as Record).Proxy === `http://127.0.0.1:${LOCAL_GATEWAY_PORT}`; + } catch { + return false; + } + } + + private async inspectInternal(): Promise { + const unsupported = this.unsupported(); + if (unsupported) return unsupported; + let facts: { executable: string; url: string }; + try { + facts = await this.tailscaleFacts(); + } catch { + return { phase: "tailscale-required", message: "Install and connect Tailscale on this Mac to enable private phone access." }; + } + try { + const service = await this.runGatewayService(["status"]); + if ( + service.exitCode === 0 + && /health:\s*healthy/iu.test(service.stdout) + && await this.hasExpectedServe(facts.executable, facts.url) + ) { + return { phase: "ready", message: "Phone access is ready on your private Tailscale network.", url: facts.url }; + } + } catch {} + return { phase: "not-configured", message: "Set up private phone access, then scan the QR code with your phone.", url: facts.url }; + } + + private async configureInternal(): Promise { + const unsupported = this.unsupported(); + if (unsupported) return unsupported; + let facts: { executable: string; url: string }; + try { + facts = await this.tailscaleFacts(); + } catch (error) { + return { phase: "tailscale-required", message: error instanceof Error ? error.message : "Tailscale is unavailable." }; + } + const service = await this.runGatewayService([ + "install", + "--origin", facts.url, + "--web-root", join(this.resourcesPath, "web"), + "--deployment-identity", await this.identity(), + "--electron-run-as-node", + ]); + if (service.exitCode !== 0) { + return { phase: "error", message: service.stderr.trim().slice(0, 512) || "The private phone gateway could not start." }; + } + const serve = suggestTailscaleServe({ + localPort: LOCAL_GATEWAY_PORT, + servePort: TAILSCALE_HTTPS_PORT, + executable: facts.executable, + }); + const result = await runProcess({ runner: this.runner, command: serve.executable, args: serve.args, timeoutMs: 10_000 }); + if (result.exitCode !== 0) { + return { phase: "error", message: result.stderr.trim().slice(0, 512) || "Tailscale Serve could not expose the private gateway." }; + } + if (!await this.hasExpectedServe(facts.executable, facts.url)) { + return { phase: "error", message: "Tailscale Serve did not keep the expected private phone route." }; + } + return { phase: "ready", message: "Phone access is ready on your private Tailscale network.", url: facts.url }; + } +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7d5e7c4..02fc0f0 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -5,6 +5,7 @@ import { decodeDesktopUpdateState, decodeProjectionCacheLoadResult, decodeProjectionCacheSaveResult, + decodePhoneSetupState, type BootstrapResult, type CommandRequest, type CommandResult, @@ -26,6 +27,7 @@ import { type PairLinksDrainResult, type PairRequest, type PairResult, + type PhoneSetupState, type RendererServerEventEnvelope, type RuntimeErrorEvent, type ServiceActionResult, @@ -87,6 +89,8 @@ export interface OmpShellBridge { readonly profileStart: (request: LocalProfileRequest) => Promise; readonly profileStop: (request: LocalProfileRequest) => Promise; readonly profileRestart: (request: LocalProfileRequest) => Promise; + readonly inspectPhoneSetup: () => Promise; + readonly configurePhoneSetup: () => Promise; readonly onServerEvent: (listener: (event: RendererServerEventEnvelope) => void) => () => void; readonly onConnectionState: (listener: (event: ConnectionStateEvent) => void) => () => void; readonly onRuntimeError: (listener: (event: RuntimeErrorEvent) => void) => () => void; @@ -96,7 +100,7 @@ export interface OmpShellBridge { readonly onOpenUpdateSettings: (listener: (event: DesktopUpdateOpenEvent) => void) => () => void; } -function invoke(channel: C, payload: unknown): Promise { +function invoke(channel: C, payload: unknown): Promise { return ipcRenderer.invoke(channel, { channel, payload }) as Promise; } @@ -178,6 +182,8 @@ const bridge: OmpShellBridge = { profileStart: (request) => invoke("omp:profiles:start", request), profileStop: (request) => invoke("omp:profiles:stop", request), profileRestart: (request) => invoke("omp:profiles:restart", request), + inspectPhoneSetup: () => invoke<"app:phone-setup:inspect", unknown>("app:phone-setup:inspect", {}).then(decodePhoneSetupState), + configurePhoneSetup: () => invoke<"app:phone-setup:configure", unknown>("app:phone-setup:configure", {}).then(decodePhoneSetupState), onServerEvent: (listener) => subscribe("omp:server-event", listener), onConnectionState: (listener) => subscribe("omp:connection-state", listener), onRuntimeError: (listener) => subscribe("omp:runtime-error", listener), diff --git a/apps/desktop/test/bundled-runtime.test.ts b/apps/desktop/test/bundled-runtime.test.ts new file mode 100644 index 0000000..f2b4e05 --- /dev/null +++ b/apps/desktop/test/bundled-runtime.test.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { installBundledOmpRuntime } from "../src/bundled-runtime.ts"; + +describe("bundled OMP runtime", () => { + it("installs the pinned executable atomically and reuses a verified install", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-bundled-runtime-")); + const resourcesPath = join(root, "resources"); + const supportPath = join(root, "support"); + const runtimeRoot = join(resourcesPath, "runtime"); + await mkdir(runtimeRoot, { recursive: true }); + const bytes = Buffer.from("synthetic omp runtime"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(join(runtimeRoot, "omp"), bytes); + await writeFile(join(runtimeRoot, "manifest.json"), JSON.stringify({ + version: 1, tag: "t4code-17.0.5-appserver-3", platform: "darwin", arch: "arm64", + executable: "omp", size: bytes.length, sha256, + })); + + const first = await installBundledOmpRuntime({ resourcesPath, applicationSupportPath: supportPath }); + const second = await installBundledOmpRuntime({ resourcesPath, applicationSupportPath: supportPath }); + + expect(second).toBe(first); + expect(await readFile(first)).toEqual(bytes); + expect((await stat(first)).mode & 0o777).toBe(0o755); + }); + + it("rejects a bundled executable that does not match its manifest", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-bundled-runtime-bad-")); + const resourcesPath = join(root, "resources"); + const runtimeRoot = join(resourcesPath, "runtime"); + await mkdir(runtimeRoot, { recursive: true }); + await writeFile(join(runtimeRoot, "omp"), "wrong"); + await writeFile(join(runtimeRoot, "manifest.json"), JSON.stringify({ + version: 1, tag: "t4code-17.0.5-appserver-3", platform: "darwin", arch: "arm64", + executable: "omp", size: 5, sha256: "0".repeat(64), + })); + + await expect(installBundledOmpRuntime({ resourcesPath, applicationSupportPath: join(root, "support") })) + .rejects.toThrow("integrity check"); + }); +}); diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts index 984e9b4..1450c03 100644 --- a/apps/desktop/test/doctor.test.ts +++ b/apps/desktop/test/doctor.test.ts @@ -37,13 +37,13 @@ function runtime(overrides: Partial = {}): DoctorRuntime { } describe("T4 setup doctor", () => { - it("uses current verified runtime metadata instead of published release provenance", async () => { + it("uses current verified runtime metadata", async () => { const source = await readSourceContract(); - expect(source.ompVersion).toBe("17.0.4"); - expect(source.ompTag).toBe("t4code-17.0.4-appserver-5"); + expect(source.ompVersion).toBe("17.0.5"); + expect(source.ompTag).toBe("t4code-17.0.5-appserver-3"); expect(source.ompUrl).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-3", ); }); diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index d20d406..5baa04a 100644 --- a/apps/desktop/test/lifecycle-runtime.test.ts +++ b/apps/desktop/test/lifecycle-runtime.test.ts @@ -382,8 +382,8 @@ describe("desktop Electron lifecycle", () => { await Promise.all([starting, stopping]); expect(calls).toEqual([]); - expect(fixture.windows).toHaveLength(0); - expect(fixture.closeCount).toBe(0); + expect(fixture.windows).toHaveLength(1); + expect(fixture.closeCount).toBe(1); }); it("does not publish a manager or startup error when recovery rejects during teardown", async () => { const calls: string[] = []; @@ -422,7 +422,7 @@ describe("desktop Electron lifecycle", () => { expect(internal.serviceManager).toBeUndefined(); expect(internal.startupServiceError).toBeUndefined(); expect(internal.serviceAvailabilityIssue).toBeUndefined(); - expect(fixture.windows).toHaveLength(0); + expect(fixture.windows).toHaveLength(1); }); it("does not publish a ready manager when teardown wins the final recovery continuation", async () => { const service: ServiceManager = { @@ -464,7 +464,7 @@ describe("desktop Electron lifecycle", () => { expect(internal.serviceManager).toBeUndefined(); expect(internal.startupServiceError).toBeUndefined(); expect(internal.serviceAvailabilityIssue).toBeUndefined(); - expect(fixture.windows).toHaveLength(0); + expect(fixture.windows).toHaveLength(1); }); it("recovers an updated OMP once across concurrent IPC retries and keeps the reason across reopen", async () => { const root = await mkdtemp(join(tmpdir(), "t4-recovery-")); diff --git a/apps/desktop/test/phone-setup.test.ts b/apps/desktop/test/phone-setup.test.ts new file mode 100644 index 0000000..f61c8ab --- /dev/null +++ b/apps/desktop/test/phone-setup.test.ts @@ -0,0 +1,83 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { ProcessRunner, ProcessSpec } from "@t4-code/remote"; +import { PhoneSetupService } from "../src/phone-setup.ts"; + +describe("phone setup", () => { + it("turns a connected Mac tailnet into a private QR destination", async () => { + const resourcesPath = await mkdtemp(join(tmpdir(), "t4-phone-setup-")); + await mkdir(join(resourcesPath, "runtime")); + await writeFile(join(resourcesPath, "runtime", "manifest.json"), '{"tag":"synthetic"}\n'); + const calls: ProcessSpec[] = []; + const runner: ProcessRunner = { + spawn: async (spec) => { + calls.push(spec); + const isStatus = spec.command === "/tailscale" && spec.args?.[0] === "status"; + const isServeStatus = spec.command === "/tailscale" && spec.args?.join(" ") === "serve status --json"; + const isGatewayInspect = spec.command === "/Applications/T4 Code.app/Contents/MacOS/T4 Code" && spec.args?.[1] === "status"; + return { + kill: () => {}, + result: Promise.resolve(isStatus + ? { exitCode: 0, signal: null, stdout: JSON.stringify({ Self: { DNSName: "work-mac.example.ts.net." } }), stderr: "", stdoutTruncated: false, stderrTruncated: false } + : isServeStatus + ? { exitCode: 0, signal: null, stdout: JSON.stringify({ TCP: { "8445": { HTTPS: true } }, Web: { "work-mac.example.ts.net:8445": { Handlers: { "/": { Proxy: "http://127.0.0.1:4194" } } } } }), stderr: "", stdoutTruncated: false, stderrTruncated: false } + : isGatewayInspect + ? { exitCode: 1, signal: null, stdout: "", stderr: "not installed", stdoutTruncated: false, stderrTruncated: false } + : { exitCode: 0, signal: null, stdout: "ok", stderr: "", stdoutTruncated: false, stderrTruncated: false }), + }; + }, + }; + const service = new PhoneSetupService({ + platform: "darwin", + arch: "arm64", + resourcesPath, + electronExecutable: "/Applications/T4 Code.app/Contents/MacOS/T4 Code", + runner, + discoverTailscale: async () => "/tailscale", + }); + + expect(await service.inspect()).toEqual({ + phase: "not-configured", + message: "Set up private phone access, then scan the QR code with your phone.", + url: "https://work-mac.example.ts.net:8445/", + }); + expect(await service.configure()).toMatchObject({ + phase: "ready", + url: "https://work-mac.example.ts.net:8445/", + }); + const serve = calls.find((call) => call.command === "/tailscale" && call.args?.[0] === "serve"); + expect(serve?.args).toEqual(["serve", "--bg", "--https=8445", "http://127.0.0.1:4194"]); + expect(JSON.stringify(calls)).not.toContain("funnel"); + const install = calls.find((call) => call.command.includes("T4 Code") && call.args?.includes("install")); + expect(install?.env).toEqual({ PATH: "/usr/bin:/bin:/usr/sbin:/sbin", ELECTRON_RUN_AS_NODE: "1" }); + expect(install?.args).toContain("--electron-run-as-node"); + }); + + it("does not show a QR code when Tailscale Serve points somewhere else", async () => { + const resourcesPath = await mkdtemp(join(tmpdir(), "t4-phone-setup-stale-")); + await mkdir(join(resourcesPath, "runtime")); + await writeFile(join(resourcesPath, "runtime", "manifest.json"), '{"tag":"synthetic"}\n'); + const runner: ProcessRunner = { + spawn: async (spec) => ({ + kill: () => {}, + result: Promise.resolve(spec.command === "/tailscale" && spec.args?.[0] === "status" + ? { exitCode: 0, signal: null, stdout: JSON.stringify({ Self: { DNSName: "work-mac.example.ts.net." } }), stderr: "", stdoutTruncated: false, stderrTruncated: false } + : spec.command === "/tailscale" + ? { exitCode: 0, signal: null, stdout: JSON.stringify({ TCP: { "8445": { HTTPS: true } }, Web: { "work-mac.example.ts.net:8445": { Handlers: { "/": { Proxy: "http://127.0.0.1:9999" } } } } }), stderr: "", stdoutTruncated: false, stderrTruncated: false } + : { exitCode: 0, signal: null, stdout: "health: healthy", stderr: "", stdoutTruncated: false, stderrTruncated: false }), + }), + }; + const service = new PhoneSetupService({ + platform: "darwin", + arch: "arm64", + resourcesPath, + electronExecutable: "/Applications/T4 Code.app/Contents/MacOS/T4 Code", + runner, + discoverTailscale: async () => "/tailscale", + }); + + expect(await service.inspect()).toMatchObject({ phase: "not-configured" }); + }); +}); diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index 12caac7..d31e60c 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -125,11 +125,11 @@ const install: DocTopic = { const firstRun: DocTopic = { id: "first-run", title: "First run", - lede: "Desktop builds can manage local Oh My Pi app servers, one per profile. Android connects to the T4 gateway on your computer.", + lede: "The Mac app brings its matching Oh My Pi backend and can set up private phone access from one screen.", blocks: [ { kind: "note", - text: "The discovery and service steps below apply to Linux and macOS. On Android, connect Tailscale to the same tailnet as your computer, then enter the gateway's full HTTPS address in T4 Code.", + text: "On an Apple Silicon Mac, T4 Code installs its pinned backend inside its own Application Support folder. It does not replace the `omp` command you may already use in Terminal.", }, { kind: "h2", id: "first-run-discovery", text: "How desktop T4 finds omp" }, { kind: "p", text: "T4 Code checks these places, in order, and uses the first match:" }, @@ -149,6 +149,11 @@ const firstRun: DocTopic = { kind: "p", text: "Before trusting a match, T4 runs `omp appserver status --json` and checks the answer. A build that cannot answer is skipped.", }, + { kind: "h2", id: "first-run-phone", text: "Use your phone" }, + { + kind: "p", + text: "Install and connect Tailscale on the Mac and phone. In T4 Code, open **Settings → Hosts**, choose **Set up phone access**, then scan the QR code. T4 Code installs the private loopback gateway and configures Tailscale Serve; it never enables public Tailscale Funnel.", + }, { kind: "h2", id: "first-run-service", text: "Who keeps the desktop app server running" }, { kind: "p", diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index c12691f..5d29375 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -5,14 +5,14 @@ export const SITE_URL = "https://t4code.net"; export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; -export const OMP_RUNTIME_VERSION = "17.0.4"; -export const OMP_RUNTIME_COMMIT = "d57dcd855006c673d8d530237d474fe5ba5645c4"; -export const OMP_RUNTIME_TAG = "t4code-17.0.4-appserver-5"; +export const OMP_RUNTIME_VERSION = "17.0.5"; +export const OMP_RUNTIME_COMMIT = "3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f"; +export const OMP_RUNTIME_TAG = "t4code-17.0.5-appserver-3"; export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; -export const OMP_UPSTREAM_TAG = "v17.0.4"; -export const OMP_UPSTREAM_COMMIT = "3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0"; +export const OMP_UPSTREAM_TAG = "v17.0.5"; +export const OMP_UPSTREAM_COMMIT = "9fd6e97113f5ed3a847e66d346970efdf8afcad9"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; -export const APP_WIRE_VERSION = "0.6.0"; +export const APP_WIRE_VERSION = "0.6.1"; export const RELEASE_TAG = "v0.1.24"; export const RELEASE_VERSION = "0.1.24"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 4039f9a..5e98543 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -61,15 +61,15 @@ describe("release assets", () => { describe("OMP integration contract", () => { it("pins the verified runtime tag, commit, and app-wire package", () => { - expect(OMP_RUNTIME_TAG).toBe("t4code-17.0.4-appserver-5"); - expect(OMP_RUNTIME_COMMIT).toBe("d57dcd855006c673d8d530237d474fe5ba5645c4"); + expect(OMP_RUNTIME_TAG).toBe("t4code-17.0.5-appserver-3"); + expect(OMP_RUNTIME_COMMIT).toBe("3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f"); expect(OMP_RUNTIME_URL).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-3", ); - expect(OMP_UPSTREAM_TAG).toBe("v17.0.4"); - expect(OMP_UPSTREAM_COMMIT).toBe("3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0"); - expect(OMP_UPSTREAM_URL).toBe("https://github.com/can1357/oh-my-pi/tree/v17.0.4"); - expect(APP_WIRE_VERSION).toBe("0.6.0"); + expect(OMP_UPSTREAM_TAG).toBe("v17.0.5"); + expect(OMP_UPSTREAM_COMMIT).toBe("9fd6e97113f5ed3a847e66d346970efdf8afcad9"); + expect(OMP_UPSTREAM_URL).toBe("https://github.com/can1357/oh-my-pi/tree/v17.0.5"); + expect(APP_WIRE_VERSION).toBe("0.6.1"); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 42c412c..1efe08b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "class-variance-authority": "^0.7.1", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "qrcode.react": "4.2.0", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", diff --git a/apps/web/src/features/targets/TargetsScreen.tsx b/apps/web/src/features/targets/TargetsScreen.tsx index e76d2ea..6c35015 100644 --- a/apps/web/src/features/targets/TargetsScreen.tsx +++ b/apps/web/src/features/targets/TargetsScreen.tsx @@ -5,7 +5,7 @@ // runtime's words, and removing a host says exactly what it does: it // deletes the credential stored on this computer, nothing more. import type { DesktopRuntimeSnapshot } from "@t4-code/client"; -import type { LocalProfile, ServiceInspection } from "@t4-code/protocol/desktop-ipc"; +import type { LocalProfile, PhoneSetupState, ServiceInspection } from "@t4-code/protocol/desktop-ipc"; import { Badge, Button, @@ -22,6 +22,7 @@ import { } from "@t4-code/ui"; import { ArrowLeft, Cable, Check, Copy, Plus, UsersRound } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; import { ToneBadge } from "../onboarding/bits.tsx"; import { FIELD_CLASS } from "../settings/controls.tsx"; @@ -178,6 +179,81 @@ function ServiceCard({ api }: { readonly api: TargetsStoreApi }) { ); } +interface PhoneSetupApi { + readonly inspect: () => Promise; + readonly configure: () => Promise; +} + +function PhoneSetupCard({ api }: { readonly api: PhoneSetupApi }) { + const { configure: configurePhone, inspect } = api; + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + useEffect(() => { + let active = true; + void inspect().then((next) => { if (active) setState(next); }, () => { + if (active) setState({ phase: "error", message: "Phone setup could not be checked." }); + }); + return () => { active = false; }; + }, [inspect]); + const configure = async () => { + setBusy(true); + try { setState(await configurePhone()); } + catch { setState({ phase: "error", message: "Phone setup could not be completed." }); } + finally { setBusy(false); } + }; + const copy = async () => { + if (!state?.url) return; + try { await navigator.clipboard.writeText(state.url); } + catch { return; } + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + }; + const ready = state?.phase === "ready" && state.url !== undefined; + return ( +
+
+
+

Use T4 Code on your phone

+

+ {state?.message ?? "Checking private phone access…"} +

+
+ +
+ {ready ? ( +
+
+ +
+
+
    +
  1. Connect your phone to the same Tailscale account.
  2. +
  3. Scan this code with your phone camera.
  4. +
  5. Choose Add to Home Screen in Safari if you want an app icon.
  6. +
+
+ {state.url} + +
+
+
+ ) : state !== null && state.phase !== "unsupported" ? ( +
+ +
+ ) : null} +
+ ); +} + // ─── Local OMP profiles ──────────────────────────────────────────────────── function serviceStatus(inspection: ServiceInspection): ServiceStatusCopy { @@ -876,6 +952,7 @@ export function TargetsScreen({ snapshot, serviceAvailable, profilesAvailable, + phoneSetup, onBack, }: { readonly api: TargetsStoreApi; @@ -884,6 +961,7 @@ export function TargetsScreen({ readonly serviceAvailable: boolean; /** Whether this desktop build exposes isolated named-profile management. */ readonly profilesAvailable: boolean; + readonly phoneSetup?: PhoneSetupApi; readonly onBack: () => void; }) { const announcement = useTargets(api, (state) => state.announcement); @@ -918,6 +996,7 @@ export function TargetsScreen({ ) : serviceAvailable ? ( ) : null} + {phoneSetup !== undefined && }

Connections diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 2eb5bee..3e4cf4d 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -414,6 +414,12 @@ function HostsRoute() { api={targetsStoreInstance} onBack={() => void navigate({ to: "/settings" })} profilesAvailable={localProfiles !== undefined} + {...(shell?.inspectPhoneSetup && shell.configurePhoneSetup ? { + phoneSetup: { + inspect: shell.inspectPhoneSetup, + configure: shell.configurePhoneSetup, + }, + } : {})} serviceAvailable={shell?.serviceInspect !== undefined} snapshot={snapshot} /> diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index 3175efe..4ed674c 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -12,21 +12,28 @@ }, "publishedAppWire": { "package": "@oh-my-pi/app-wire", - "version": "0.6.0", + "version": "0.6.1", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", - "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499" + "sourceCommit": "e3e15c03ae95ebbda5f26495cd21213cc53518b1", + "sourceTreeHash": "e0f32b279eb4b8cbc403e47d765a226bee99c99f" }, "publishedRuntime": { "package": "omp", - "version": "17.0.4", + "version": "17.0.5", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "d57dcd855006c673d8d530237d474fe5ba5645c4", - "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4", - "sourceTag": "t4code-17.0.4-appserver-5", + "sourceCommit": "3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f", + "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f", + "sourceTag": "t4code-17.0.5-appserver-3", + "artifacts": { + "darwin-arm64": { + "name": "omp-darwin-arm64", + "size": 120760912, + "sha256": "e289e04cb6dde192b9a521e3586fc74e549dedd8044b96f56b7241a90d19c034" + } + }, "upstreamRepository": "https://github.com/can1357/oh-my-pi", - "upstreamTag": "v17.0.4", - "upstreamCommit": "3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0", + "upstreamTag": "v17.0.5", + "upstreamCommit": "9fd6e97113f5ed3a847e66d346970efdf8afcad9", "integrationPatches": [ "bounded-growing-session-replay", "complete-session-event-projection", @@ -81,20 +88,30 @@ "retry-safe-integration-release-metadata", "versioned-agent-view-lifecycle-corpus", "session-owned-agent-cancellation", - "codex-provider-transport-diagnostics" + "codex-provider-transport-diagnostics", + "cross-session-attention-contract", + "cross-session-transcript-search", + "fast-appserver-startup" ], "upstreamTagContainsIntegrationPatches": false }, "verifiedRuntime": { "package": "omp", - "version": "17.0.4", + "version": "17.0.5", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "d57dcd855006c673d8d530237d474fe5ba5645c4", - "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4", - "sourceTag": "t4code-17.0.4-appserver-5", + "sourceCommit": "3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f", + "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f", + "sourceTag": "t4code-17.0.5-appserver-3", + "artifacts": { + "darwin-arm64": { + "name": "omp-darwin-arm64", + "size": 120760912, + "sha256": "e289e04cb6dde192b9a521e3586fc74e549dedd8044b96f56b7241a90d19c034" + } + }, "upstreamRepository": "https://github.com/can1357/oh-my-pi", - "upstreamTag": "v17.0.4", - "upstreamCommit": "3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0", + "upstreamTag": "v17.0.5", + "upstreamCommit": "9fd6e97113f5ed3a847e66d346970efdf8afcad9", "integrationPatches": [ "bounded-growing-session-replay", "complete-session-event-projection", @@ -149,7 +166,10 @@ "retry-safe-integration-release-metadata", "versioned-agent-view-lifecycle-corpus", "session-owned-agent-cancellation", - "codex-provider-transport-diagnostics" + "codex-provider-transport-diagnostics", + "cross-session-attention-contract", + "cross-session-transcript-search", + "fast-appserver-startup" ], "upstreamTagContainsIntegrationPatches": false }, diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index afd58cc..63da300 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -24,11 +24,11 @@ Session-linked browser previews now open in a dedicated workspace. The client pr ## Runtime provenance -T4 Code v0.1.24 vendors app-wire 0.6.0 from integration commit [ae4b53b4](https://github.com/lyc-aon/oh-my-pi/commit/ae4b53b416f32b200865a32ed9baabd5a4666fa4), source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`. The client contract remains `omp-app/1`. +T4 Code v0.1.24 vendors app-wire 0.6.1 from integration commit [e3e15c03](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. The client contract remains `omp-app/1`. -The verified OMP 17.0.4 runtime is built from commit [d57dcd85](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4) and tagged [t4code-17.0.4-appserver-5](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). It provides the stable appserver base used by the desktop and remote workflows. Newer optional capabilities remain hidden when the host does not advertise them. +The verified OMP 17.0.5 runtime is built from commit [3393ae0f](https://github.com/lyc-aon/oh-my-pi/commit/3393ae0f7fc5b2ea9919d8bdb3a2d5719b1cbc2f) and tagged [t4code-17.0.5-appserver-3](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-3). It provides the stable appserver base used by the desktop and remote workflows, including faster startup, cross-session attention, and cross-session transcript search. Newer optional capabilities remain hidden when the host does not advertise them. -The integration is based on the official upstream [v17.0.4 tag](https://github.com/can1357/oh-my-pi/tree/v17.0.4), commit [3fdd85ab](https://github.com/can1357/oh-my-pi/commit/3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0). Official upstream OMP v17.0.4 has no `appserver` command and cannot host T4 Code. +The integration is based on the official upstream [v17.0.5 tag](https://github.com/can1357/oh-my-pi/tree/v17.0.5), commit [9fd6e971](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). Official upstream OMP v17.0.5 has no `appserver` command and cannot host T4 Code. ## Packages diff --git a/docs/TAILNET_REMOTE.md b/docs/TAILNET_REMOTE.md index 324f200..7177b1f 100644 --- a/docs/TAILNET_REMOTE.md +++ b/docs/TAILNET_REMOTE.md @@ -16,26 +16,39 @@ interface. It accepts WebSocket connections from the exact configured `.ts.net` HTTPS origin and the two fixed Capacitor WebView origins described below. No token or password is passed through the browser or mobile UI. -> This is a source-hosted feature in the current release. The downloadable -> desktop packages do not install or manage the Tailnet gateway. Keep the -> checkout used to install the service in place, or reinstall the service from -> its new path after moving it. +The Apple Silicon Mac package contains the gateway. Open **Settings → Hosts**, +choose **Set up phone access**, and scan the QR code after setup finishes. T4 +Code installs a per-user background service and configures tailnet-only +Tailscale Serve. The source procedure below remains available for Linux and +for maintainers who need a custom gateway layout. ## Prerequisites - Linux with a systemd user session, or macOS with a logged-in launchd GUI session. -- Node.js 24 and pnpm 11 (the versions declared by this repository). +- Node.js 24 and pnpm 11 only when using the manual source procedure. - Tailscale installed, signed in, and using MagicDNS/HTTPS. - A running local OMP appserver. Opening the T4 desktop app normally installs and starts it; `omp appserver status --json` is a direct check. -- A T4 Code source checkout with dependencies installed. +- A T4 Code source checkout with dependencies installed only for manual setup. Do not use Tailscale Funnel for this. Funnel is the public-internet product; this setup is meant to remain tailnet-only. Tailscale documents the distinction and current Serve syntax in its [Serve reference](https://tailscale.com/docs/reference/tailscale-cli/serve). -## Install +## Automatic Mac setup + +1. Install and sign in to Tailscale on the Mac and phone. +2. Open T4 Code on the Mac and go to **Settings → Hosts**. +3. Choose **Set up phone access**. +4. Scan the displayed QR code with the phone camera. +5. In Safari, optionally choose **Add to Home Screen** for an app icon. + +This action is explicit because it changes this Mac's Tailscale Serve +configuration. It binds only the private tailnet HTTPS address to the gateway +on `127.0.0.1:4194`; it does not open a LAN listener or enable Funnel. + +## Manual source install Choose the HTTPS port you will open on your tailnet. The examples use `8445`. Find this machine's full MagicDNS name with `tailscale status`; it looks like diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index 5b4f5a8..d6e5925 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -59,6 +59,12 @@ const config = { // updater feed disabled until signed-to-signed update migration has its // own release proof. publish: [], + extraResources: [ + { from: ".artifacts/omp-runtime", to: "runtime" }, + { from: "scripts/tailnet-gateway.mjs", to: "gateway/tailnet-gateway.mjs" }, + { from: "scripts/tailnet-service.mjs", to: "gateway/tailnet-service.mjs" }, + { from: "apps/desktop/node_modules/ws", to: "node_modules/ws" }, + ], target: [ { target: "dmg", arch: ["arm64"] }, { target: "zip", arch: ["arm64"] }, diff --git a/package.json b/package.json index aab9039..e11aab7 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", "package:mac:unsigned": "node scripts/package-mac-unsigned.mjs", "package:mac": "node scripts/package-mac-signed.mjs", + "stage:omp-runtime:mac": "node scripts/stage-omp-runtime.mjs --platform darwin --arch arm64", "package:dir": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64 --dir", "inspect:package": "node scripts/inspect-package.mjs", "inspect:dmg": "node scripts/inspect-macos-dmg.mjs", diff --git a/packages/client/src/desktop-runtime-contracts.ts b/packages/client/src/desktop-runtime-contracts.ts index 479418e..4ed345b 100644 --- a/packages/client/src/desktop-runtime-contracts.ts +++ b/packages/client/src/desktop-runtime-contracts.ts @@ -21,6 +21,7 @@ import type { PairRequest, PairResult, PairLinksDrainResult, + PhoneSetupState, RendererServerEventEnvelope, RendererServerEvent, RuntimeErrorEvent, @@ -81,6 +82,8 @@ export interface DesktopShellPort { readonly updateRendererReady?: () => Promise; readonly loadProjectionCache?: () => Promise; readonly saveProjectionCache?: (request: ProjectionCacheSaveRequest) => Promise; + readonly inspectPhoneSetup?: () => Promise; + readonly configurePhoneSetup?: () => Promise; readonly listTargets: () => Promise; readonly addTarget: (request: TargetAddRequest) => Promise; readonly removeTarget: (request: TargetRequest) => Promise; diff --git a/packages/protocol/src/desktop-ipc.ts b/packages/protocol/src/desktop-ipc.ts index c4d7c0e..ae162c5 100644 --- a/packages/protocol/src/desktop-ipc.ts +++ b/packages/protocol/src/desktop-ipc.ts @@ -75,6 +75,8 @@ export const DESKTOP_IPC_CHANNELS = [ "app:update:renderer-ready", "app:projection-cache:load", "app:projection-cache:save", + "app:phone-setup:inspect", + "app:phone-setup:configure", ] as const; export type DesktopInvokeChannel = (typeof DESKTOP_IPC_CHANNELS)[number]; export const DESKTOP_IPC_EVENTS = [ @@ -111,6 +113,33 @@ export interface ServiceActionRequest {} export interface ServiceActionResult { completed: true; } +export type PhoneSetupPhase = "unsupported" | "tailscale-required" | "not-configured" | "ready" | "error"; +export interface PhoneSetupState { + readonly phase: PhoneSetupPhase; + readonly message: string; + readonly url?: string; +} +export interface PhoneSetupRequest {} +export function decodePhoneSetupState(value: unknown): PhoneSetupState { + const item = object(value, "phone setup state"); + exact(item, ["phase", "message", "url"]); + if (!["unsupported", "tailscale-required", "not-configured", "ready", "error"].includes(item.phase as string)) { + throw new Error("invalid phone setup phase"); + } + const message = controlFree(item.message, "phone setup message", 512); + let url: string | undefined; + if (item.url !== undefined) { + const parsed = new URL(controlFree(item.url, "phone setup URL", 2_048)); + if ( + parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || + !parsed.hostname.endsWith(".ts.net") || parsed.port !== "8445" || parsed.pathname !== "/" || + parsed.search !== "" || parsed.hash !== "" + ) throw new Error("invalid phone setup URL"); + url = parsed.toString(); + } + if (item.phase === "ready" && url === undefined) throw new Error("ready phone setup requires a URL"); + return Object.freeze({ phase: item.phase as PhoneSetupPhase, message, ...(url === undefined ? {} : { url }) }); +} export interface LocalProfile { readonly profileId: string; readonly label: string; @@ -477,6 +506,8 @@ export interface DesktopInvokeRequestMap { "app:update:renderer-ready": DesktopUpdateRequest; "app:projection-cache:load": ProjectionCacheLoadRequest; "app:projection-cache:save": ProjectionCacheSaveRequest; + "app:phone-setup:inspect": PhoneSetupRequest; + "app:phone-setup:configure": PhoneSetupRequest; } export interface DesktopInvokeResponseMap { "omp:targets:list": TargetListResult; @@ -515,6 +546,8 @@ export interface DesktopInvokeResponseMap { "app:update:renderer-ready": DesktopUpdateRendererReadyResult; "app:projection-cache:load": ProjectionCacheLoadResult; "app:projection-cache:save": ProjectionCacheSaveResult; + "app:phone-setup:inspect": PhoneSetupState; + "app:phone-setup:configure": PhoneSetupState; } export interface RendererServerEventEnvelope { targetId: string; @@ -881,6 +914,8 @@ export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest case "app:update:download": case "app:update:restart": case "app:update:renderer-ready": + case "app:phone-setup:inspect": + case "app:phone-setup:configure": exact(payload, []); return { channel, payload: {} }; case "app:projection-cache:load": diff --git a/packages/protocol/test/desktop-ipc.test.ts b/packages/protocol/test/desktop-ipc.test.ts index 83856f8..4f1e6f9 100644 --- a/packages/protocol/test/desktop-ipc.test.ts +++ b/packages/protocol/test/desktop-ipc.test.ts @@ -8,6 +8,7 @@ import { decodeProjectionCacheLoadResult, decodeProjectionCacheSaveRequestValue, decodeProjectionCacheSaveResult, + decodePhoneSetupState, decodeSpeechText, MAX_PROJECTION_CACHE_BYTES, MAX_SPEECH_TEXT_BYTES, @@ -15,6 +16,19 @@ import { } from "../src/desktop-ipc.ts"; describe("desktop IPC boundary", () => { + it("accepts only private root Tailnet URLs for phone setup", () => { + expect(decodePhoneSetupState({ + phase: "ready", + message: "Ready", + url: "https://work-mac.example.ts.net:8445/", + })).toEqual({ phase: "ready", message: "Ready", url: "https://work-mac.example.ts.net:8445/" }); + for (const url of [ + "https://example.com:8445/", + "https://work-mac.example.ts.net:8445/path", + "https://user:secret@work-mac.example.ts.net:8445/", + "http://work-mac.example.ts.net:8445/", + ]) expect(() => decodePhoneSetupState({ phase: "ready", message: "Ready", url })).toThrow(); + }); it("keeps bounded actionable command errors while redacting secret-shaped details", () => { const error = commandResultError({ code: "stale_revision", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e76bf12..6fcbec4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + qrcode.react: + specifier: 4.2.0 + version: 4.2.0(react@19.2.6) react: specifier: 'catalog:' version: 19.2.6 @@ -2709,6 +2712,11 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -4171,12 +4179,12 @@ snapshots: - utf-8-validate - vite - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)))': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw @@ -4200,7 +4208,7 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)))': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) @@ -4209,7 +4217,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -5998,6 +6006,10 @@ snapshots: pvutils@1.1.5: {} + qrcode.react@4.2.0(react@19.2.6): + dependencies: + react: 19.2.6 + quick-lru@5.1.1: {} react-dom@19.2.6(react@19.2.6): @@ -6476,8 +6488,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 @@ -6490,7 +6502,7 @@ snapshots: oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) oxlint-tsgolint: 0.24.0 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' - vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -6558,7 +6570,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): + vitest@4.1.9(@types/node@26.1.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) @@ -6582,7 +6594,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.9) transitivePeerDependencies: - msw diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 3682b3e..228f9a7 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -82,7 +82,7 @@ test("rejects duplicate keys in JSON release contracts", () => { test("keeps verified and published runtime records aligned after promotion", () => { const matrix = JSON.parse(files.get("compat/omp-app-matrix.json")); - assert.equal(matrix.verifiedRuntime.sourceTag, "t4code-17.0.4-appserver-5"); + assert.equal(matrix.verifiedRuntime.sourceTag, "t4code-17.0.5-appserver-3"); assert.deepEqual(matrix.publishedRuntime, matrix.verifiedRuntime); }); @@ -142,7 +142,7 @@ test("tagged releases reject published provenance drift", () => { [ "tag", (runtime) => { - runtime.sourceTag = "t4code-17.0.4-appserver-3"; + runtime.sourceTag = "t4code-17.0.5-appserver-2"; }, ], [ diff --git a/scripts/package-mac-signed.mjs b/scripts/package-mac-signed.mjs index f06616f..1cac1b8 100644 --- a/scripts/package-mac-signed.mjs +++ b/scripts/package-mac-signed.mjs @@ -22,7 +22,18 @@ if (missingEnvironment.length > 0) { process.exit(1); } -const signedEnvironment = { ...process.env, T4_MACOS_SIGNED_BUILD: "1" }; +const stageRuntime = spawnSync("pnpm", ["stage:omp-runtime:mac"], { + cwd: repoRoot, + stdio: "inherit", +}); +if (stageRuntime.error) throw stageRuntime.error; +if (stageRuntime.status !== 0) process.exit(stageRuntime.status ?? 1); + +const signedEnvironment = { + ...process.env, + T4_MACOS_SIGNED_BUILD: "1", + T4_REQUIRE_BUNDLED_OMP: "1", +}; const prepackage = spawnSync("pnpm", ["prepackage"], { cwd: repoRoot, env: signedEnvironment, @@ -32,4 +43,5 @@ if (prepackage.error) throw prepackage.error; if (prepackage.status !== 0) process.exit(prepackage.status ?? 1); process.env.T4_MACOS_SIGNED_BUILD = "1"; +process.env.T4_REQUIRE_BUNDLED_OMP = "1"; process.exitCode = runElectronBuilder(["--mac", "--arm64", ...process.argv.slice(2)]); diff --git a/scripts/package-mac-unsigned.mjs b/scripts/package-mac-unsigned.mjs index 9d362e3..c4df2b8 100644 --- a/scripts/package-mac-unsigned.mjs +++ b/scripts/package-mac-unsigned.mjs @@ -9,9 +9,21 @@ if (process.platform !== "darwin") { process.exit(1); } +const stageRuntime = spawnSync("pnpm", ["stage:omp-runtime:mac"], { + cwd: repoRoot, + stdio: "inherit", +}); +if (stageRuntime.error) throw stageRuntime.error; +if (stageRuntime.status !== 0) process.exit(stageRuntime.status ?? 1); + const prepackage = spawnSync("pnpm", ["prepackage"], { cwd: repoRoot, - env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false", T4_MACOS_SIGNED_BUILD: "0" }, + env: { + ...process.env, + CSC_IDENTITY_AUTO_DISCOVERY: "false", + T4_MACOS_SIGNED_BUILD: "0", + T4_REQUIRE_BUNDLED_OMP: "1", + }, stdio: "inherit", }); if (prepackage.error) throw prepackage.error; @@ -19,4 +31,5 @@ if (prepackage.status !== 0) process.exit(prepackage.status ?? 1); process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; process.env.T4_MACOS_SIGNED_BUILD = "0"; +process.env.T4_REQUIRE_BUNDLED_OMP = "1"; process.exitCode = runElectronBuilder(["--mac", "--arm64", ...process.argv.slice(2)]); diff --git a/scripts/package-preflight.mjs b/scripts/package-preflight.mjs index c7b31a6..748822f 100644 --- a/scripts/package-preflight.mjs +++ b/scripts/package-preflight.mjs @@ -1,4 +1,5 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import { createHash } from "node:crypto"; import { join, relative, resolve, sep } from "node:path"; import { verifyDesktopIcon } from "./desktop-icon-checks.mjs"; @@ -64,6 +65,24 @@ export function runPreflight(repoRoot = resolve(import.meta.dirname, "..")) { const preloadEntry = join(electronDist, "preload.cjs"); const errors = []; + if (process.env.T4_REQUIRE_BUNDLED_OMP === "1") { + const runtimeRoot = join(repoRoot, ".artifacts", "omp-runtime"); + const manifestPath = join(runtimeRoot, "manifest.json"); + const executablePath = join(runtimeRoot, "omp"); + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const matrix = JSON.parse(readFileSync(join(repoRoot, "compat", "omp-app-matrix.json"), "utf8")); + const pinned = matrix.verifiedRuntime?.artifacts?.["darwin-arm64"]; + const digest = createHash("sha256").update(readFileSync(executablePath)).digest("hex"); + if ( + manifest.tag !== matrix.verifiedRuntime?.sourceTag || manifest.sha256 !== pinned?.sha256 || + manifest.size !== pinned?.size || lstatSync(executablePath).size !== pinned?.size || digest !== pinned?.sha256 + ) errors.push("staged OMP runtime does not match compat/omp-app-matrix.json"); + } catch { + errors.push("missing or invalid staged OMP runtime"); + } + } + for (const required of [join(electronDist, "main.cjs"), preloadEntry, join(webDist, "index.html")]) { if (!existsSync(required) || !lstatSync(required).isFile() || lstatSync(required).size === 0) { errors.push(`missing built entry: ${relative(repoRoot, required)}`); diff --git a/scripts/stage-omp-runtime.mjs b/scripts/stage-omp-runtime.mjs new file mode 100644 index 0000000..e706683 --- /dev/null +++ b/scripts/stage-omp-runtime.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const matrix = JSON.parse(await readFile(join(repoRoot, "compat", "omp-app-matrix.json"), "utf8")); +const option = (name) => { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? undefined : process.argv[index + 1]; +}; +const platform = option("platform"); +const arch = option("arch"); +const key = `${platform}-${arch}`; +const runtime = matrix.verifiedRuntime; +const artifact = runtime?.artifacts?.[key]; +if (!artifact || !/^[a-z0-9][a-z0-9._-]{1,80}$/u.test(artifact.name) || !/^[0-9a-f]{64}$/u.test(artifact.sha256)) { + throw new Error(`compat/omp-app-matrix.json has no valid ${key} runtime artifact`); +} +const outputRoot = join(repoRoot, ".artifacts", "omp-runtime"); +const output = join(outputRoot, "omp"); +const temporary = `${output}.partial-${process.pid}`; +const url = `${runtime.sourceRepository}/releases/download/${runtime.sourceTag}/${artifact.name}`; + +async function sha256(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +await mkdir(outputRoot, { recursive: true, mode: 0o700 }); +let current; +try { + current = await stat(output); +} catch {} +if (!current || current.size !== artifact.size || (await sha256(output)) !== artifact.sha256) { + const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) }); + if (!response.ok || !response.body) throw new Error(`runtime download failed with HTTP ${response.status}`); + await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary, { flags: "wx", mode: 0o600 })); + const downloaded = await stat(temporary); + if (downloaded.size !== artifact.size || (await sha256(temporary)) !== artifact.sha256) { + await unlink(temporary).catch(() => {}); + throw new Error("downloaded OMP runtime does not match the pinned size and SHA-256 digest"); + } + await chmod(temporary, 0o755); + await rename(temporary, output); +} +await writeFile( + join(outputRoot, "manifest.json"), + `${JSON.stringify({ version: 1, tag: runtime.sourceTag, platform, arch, executable: basename(output), size: artifact.size, sha256: artifact.sha256 }, null, 2)}\n`, + { mode: 0o600 }, +); +console.log(`staged ${runtime.sourceTag} ${key} runtime`); diff --git a/scripts/t4-maintainer-integration.test.mjs b/scripts/t4-maintainer-integration.test.mjs index ea46401..85ef8f1 100644 --- a/scripts/t4-maintainer-integration.test.mjs +++ b/scripts/t4-maintainer-integration.test.mjs @@ -25,6 +25,7 @@ const repoRoot = resolve(import.meta.dirname, ".."); const deployScript = resolve(repoRoot, "ops/t4-maintainer/deploy-local.sh"); const runnerScript = resolve(repoRoot, "ops/t4-maintainer/run.sh"); const bashPath = "/bin/bash"; +const integrationProcessTimeoutMs = 30_000; const upstreamCommit = "a".repeat(40); const integrationCommit = "b".repeat(40); const t4Commit = "c".repeat(40); @@ -1570,7 +1571,7 @@ exec "$@" return spawnSync(bashPath, [deployScript, result, receipt, work], { encoding: "utf8", env: { ...env, ...extraEnv }, - timeout: 20_000, + timeout: integrationProcessTimeoutMs, }); }, async callsText() { @@ -1745,7 +1746,7 @@ async function createRunnerFixture(options = {}) { return spawnSync(bashPath, [runnerScript, ...args], { encoding: "utf8", env: { ...runnerEnv, ...extraEnv }, - timeout: 20_000, + timeout: integrationProcessTimeoutMs, }); }, }; diff --git a/scripts/tailnet-service.mjs b/scripts/tailnet-service.mjs index 3957416..795e46c 100644 --- a/scripts/tailnet-service.mjs +++ b/scripts/tailnet-service.mjs @@ -170,6 +170,7 @@ export function validateServiceConfig(input) { port: gatewayPort(input.port ?? DEFAULT_GATEWAY_PORT), label: cleanText(input.label ?? "OMP on this Tailnet host", "host label", 128), deploymentIdentity: deploymentIdentity(input.deploymentIdentity), + ...(input.electronRunAsNode === true ? { electronRunAsNode: true } : {}), ...(routes === undefined ? {} : { profileRoutes: routes, startProfiles: input.startProfiles === true }), }; if (input.version !== undefined && input.version !== CONFIG_VERSION) fail("service config version is unsupported"); @@ -198,6 +199,7 @@ function gatewayEnvironment(config) { T4_APP_SERVER_SOCKET: config.appSocket, T4_HOST_LABEL: config.label, T4_DEPLOYMENT_IDENTITY: config.deploymentIdentity, + ...(config.electronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}), ...(config.profileRoutes === undefined ? {} : { @@ -568,7 +570,7 @@ export function parseCli(argv) { } if (!flag?.startsWith("--")) fail(`unexpected argument: ${flag}`); const key = flag.slice(2).replaceAll(/-([a-z])/gu, (_match, letter) => letter.toUpperCase()); - if (flag === "--defer-start" || flag === "--start-profiles") { + if (flag === "--defer-start" || flag === "--start-profiles" || flag === "--electron-run-as-node") { if (key in options) fail(`${flag} was provided more than once`); options[key] = true; continue; @@ -595,6 +597,7 @@ export function validateCliOptions(command, options) { "profileRoutes", "startProfiles", "deferStart", + "electronRunAsNode", ]) : new Set(["help"]); for (const key of Object.keys(options)) { @@ -619,6 +622,8 @@ Install options: --label TEXT Host label shown by T4 Code --deployment-identity SHA256:HEX Immutable identity for the exact deployed T4/OMP tuple (required) + --electron-run-as-node + Run the gateway with Electron's bundled Node runtime --defer-start Install the definition durably disabled and stopped This manages only the loopback gateway service. Configure Tailscale Serve separately. @@ -640,6 +645,7 @@ async function install(options, paths) { port: options.port ?? DEFAULT_GATEWAY_PORT, label: options.label ?? "OMP on this Tailnet host", deploymentIdentity: options.deploymentIdentity, + electronRunAsNode: options.electronRunAsNode === true, ...(options.profileRoutes === undefined ? {} : {