diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml new file mode 100644 index 0000000000000..36dfb61f73f54 --- /dev/null +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -0,0 +1,134 @@ +name: Mobile Showcase Screenshots + +on: + workflow_dispatch: + inputs: + platform: + description: Device platforms to capture + required: true + default: all + type: choice + options: + - all + - ios + - android + appearance: + description: System appearances to capture + required: true + default: both + type: choice + options: + - both + - dark + - light + +permissions: + contents: read + +env: + NODE_OPTIONS: --max-old-space-size=8192 + +jobs: + ios: + name: iPhone 6.9, iPhone 6.5, and iPad 13 + if: inputs.platform == 'all' || inputs.platform == 'ios' + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Capture iOS showcase + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" + + - name: Validate App Store Connect assets + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only + + - name: Upload iOS screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-store-connect-screenshots + path: artifacts/app-store/screenshots/apple/ + if-no-files-found: warn + retention-days: 14 + + android: + name: Android phone, 7-inch tablet, and 10-inch tablet + if: inputs.platform == 'all' || inputs.platform == 'android' + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 60 + env: + T3_SHOWCASE_ANDROID_ABI: x86_64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v5 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Capture Android showcase + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + avd-name: Pixel_10_Pro + cores: 8 + ram-size: 4096M + disable-animations: false + script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" + + - name: Validate Google Play assets + run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only + + - name: Upload Android screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: google-play-screenshots + path: artifacts/app-store/screenshots/google-play/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index ef6067824f2b2..8e1669c8115a6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ +.showcase/ +apps/mobile/.showcase/ +artifacts/app-store/screenshots/ node_modules/ .alchemy/ *.log diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 081ef903a0663..929afeeabe9c1 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 12; +const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -220,11 +220,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { } } -function patchMainBundleInfoPlist(appBundlePath, iconPath) { +function patchMainBundleInfoPlist(appBundlePath, iconPath, executableName) { const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleExecutable", executableName); setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); setPlistJson(infoPlistPath, "CFBundleURLTypes", [ { @@ -277,11 +278,25 @@ function readJson(path) { } } +export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { + const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const launcherExecutableName = `${displayName} Launcher`; + return { + launcherExecutableName, + launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + }; +} + function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const developmentPaths = resolveMacLauncherPaths(targetAppBundlePath); + const runtimeElectronBinaryPath = developmentPaths.runtimeElectronBinaryPath; + const launcherBinaryPath = isDevelopment + ? developmentPaths.launcherBinaryPath + : runtimeElectronBinaryPath; const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = NodePath.join(runtimeDir, "metadata.json"); @@ -298,7 +313,8 @@ function buildMacLauncher(electronBinaryPath) { const currentMetadata = readJson(metadataPath); if ( - NodeFS.existsSync(targetBinaryPath) && + NodeFS.existsSync(launcherBinaryPath) && + (!isDevelopment || NodeFS.existsSync(runtimeElectronBinaryPath)) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -306,10 +322,10 @@ function buildMacLauncher(electronBinaryPath) { // The launcher also handles protocol activations outside the dev runner, // so refresh its fallback environment on every launch. Never let a value // captured by an older parent app override the live dev-runner environment. - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); @@ -321,15 +337,24 @@ function buildMacLauncher(electronBinaryPath) { recursive: true, verbatimSymlinks: true, }); - patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchMainBundleInfoPlist( + targetAppBundlePath, + iconPath, + isDevelopment ? developmentPaths.launcherExecutableName : "Electron", + ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + // Keep Electron's native executable inside the branded bundle. Launching the + // node_modules copy makes macOS associate the process (and Dock label) with + // Electron.app even though this bundle's Info.plist has the T3 Code name. + // Its conventional executable name also keeps Electron's default-app runtime + // in development mode instead of making app.isPackaged report true. + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 36e9429e4446f..1c82167ea2171 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,6 +1,10 @@ import { assert, describe, it } from "vite-plus/test"; -import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs"; +import { + makeDevelopmentLauncherScript, + resolveElectronBinaryPath, + resolveMacLauncherPaths, +} from "./electron-launcher.mjs"; describe("electron development launcher", () => { it("uses captured values only as fallbacks for a live runner environment", () => { @@ -45,4 +49,33 @@ describe("electron development launcher", () => { ); assert.deepEqual(calls, ["ensure", "require:electron"]); }); + + it("keeps the native Electron executable name inside the branded macOS bundle", () => { + const paths = resolveMacLauncherPaths( + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app", + "T3 Code (Dev)", + ); + + assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher"); + assert.equal( + paths.launcherBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher", + ); + assert.equal( + paths.runtimeElectronBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron", + ); + + const script = makeDevelopmentLauncherScript({ + electronBinaryPath: paths.runtimeElectronBinaryPath, + mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", + desktopRoot: "/repo/apps/desktop", + environment: {}, + }); + assert.include( + script, + "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'", + ); + assert.notInclude(script, "node_modules/electron"); + }); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 3e5bb5d4d6893..6c2b69a00e6bb 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ autoInstallOnAppQuit: true, channel: "latest", disableDifferentialDownload: false, + fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.autoInstallOnAppQuit = true; autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.fullChangelog = false; autoUpdaterMock.checkForUpdates.mockClear(); autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); @@ -108,6 +110,18 @@ describe("ElectronUpdater", () => { }).pipe(Effect.provide(ElectronUpdater.layer)), ); + it.effect("sets full changelog mode", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFullChangelog(true); + assert.equal(autoUpdaterMock.fullChangelog, true); + + yield* updater.setFullChangelog(false); + assert.equal(autoUpdaterMock.fullChangelog, false); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + it.effect("preserves quit-and-install flags and the execution-time channel", () => Effect.gen(function* () { const cause = new Error("quit and install failed"); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index a2de717635e48..fbb0af24264d1 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -98,6 +98,7 @@ export class ElectronUpdater extends Context.Service< readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly verifyAvailable: Effect.Effect; readonly checkForUpdates: Effect.Effect; @@ -163,6 +164,14 @@ export const make = ElectronUpdater.of({ }), ), ), + setFullChangelog: (value) => + loadAutoUpdater.pipe( + Effect.flatMap((autoUpdater) => + Effect.sync(() => { + autoUpdater.fullChangelog = value; + }), + ), + ), setDisableDifferentialDownload: (value) => loadAutoUpdater.pipe( Effect.flatMap((autoUpdater) => diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 80ff466d72b8c..ac26579d45a0e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -40,6 +40,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { let verifyCount = 0; let allowDowngrade = false; const disableDifferentialDownloadValues: boolean[] = []; + let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -75,6 +76,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), setDisableDifferentialDownload: (value) => Effect.sync(() => { disableDifferentialDownloadValues.push(value); @@ -196,6 +201,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { feedUrls: () => feedUrls, verifyCount: () => verifyCount, disableDifferentialDownloadValues: () => disableDifferentialDownloadValues, + fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -317,6 +323,49 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.setChannel("nightly"); + assert.equal(harness.fullChangelog(), true); + + harness.emit("update-available", { + version: "1.2.4-nightly.20260709.766", + releaseNotes: [ + { + version: "1.2.4-nightly.20260709.766", + note: `

What's Changed

Full Changelog

`, + }, + { + version: "1.2.4-nightly.20260709.765", + note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", + }, + ], + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.deepEqual(state.releaseNotes, [ + { + version: "1.2.4-nightly.20260709.766", + items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + }, + { + version: "1.2.4-nightly.20260709.765", + items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + }, + ]); + assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("keeps raw updater event failures out of update state", () => { const harness = makeHarness(); const cause = new Error( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6058b0ae25f57..ff62bef939d15 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -51,6 +52,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, + // Left unvalidated on purpose: a malformed release-notes payload must never + // fail the decode and block the update state transition. The shape is + // validated defensively in normalizeDesktopUpdateReleaseNotes. + releaseNotes: Schema.optional(Schema.Unknown), }); const DownloadProgressInfo = Schema.Struct({ @@ -332,10 +337,12 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, allowDowngrade: allowsPrerelease, + fullChangelog: allowsPrerelease, }); }); @@ -572,11 +579,15 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { version: info.version }); + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: releaseNotes.length, + }); }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts new file mode 100644 index 0000000000000..9d6bbaea6bcb2 --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; + +describe("normalizeDesktopUpdateReleaseNotes", () => { + it("splits a plain string note into items under the fallback version", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + "## What's changed\n- First fix\n- Second fix", + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + }); + + it("keeps per-version groups and drops empty ones", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.2.1", note: "- Older change" }, + ], + "1.2.3", + ); + expect(notes).toEqual([ + { version: "1.2.3", items: ["Newer change"] }, + { version: "1.2.1", items: ["Older change"] }, + ]); + }); + + it("decodes valid HTML entities", () => { + const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + }); + + it("ignores malformed entries instead of throwing", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Valid change" }, + { version: 42, note: "- Bad version type" }, + { version: "1.2.1", note: { html: "

object note

" } }, + "not an object", + null, + ], + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + }); + + it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { + const boilerplate = Array.from({ length: 7 }, (_, index) => ({ + version: `1.3.${9 - index}`, + note: "Full changelog: https://example.com/compare/x...y", + })); + const notes = normalizeDesktopUpdateReleaseNotes( + [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], + "1.3.9", + ); + expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + }); + + it("does not throw on out-of-range numeric entities and keeps the literal", () => { + expect(() => + normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), + ).not.toThrow(); + const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts new file mode 100644 index 0000000000000..69857c92b3f1f --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -0,0 +1,125 @@ +import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +interface ElectronReleaseNoteInfo { + readonly version: string; + readonly note: string | null | undefined; +} + +function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { readonly version?: unknown; readonly note?: unknown }; + return ( + typeof candidate.version === "string" && + (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) + ); +} + +const MAX_RELEASE_NOTE_GROUPS = 6; +const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; +const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; + +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeCodePoint(codePoint: number, entity: string): string { + // String.fromCodePoint throws RangeError outside the valid Unicode range, and + // Number.isFinite alone lets oversized values (e.g. �) through. + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return `&${entity};`; + } + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntity(entity: string): string { + const named = HTML_ENTITY_REPLACEMENTS[entity]; + if (named) return named; + if (entity.startsWith("#x")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); + } + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); + } + return `&${entity};`; +} + +function decodeHtmlEntities(input: string): string { + return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => + decodeHtmlEntity(entity), + ); +} + +function stripMarkup(input: string): string { + return decodeHtmlEntities( + input + .replace(//gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"), + ); +} + +function truncateReleaseNoteItem(item: string): string { + if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; + return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = line + .toLowerCase() + .replace(/[*_`#]/g, "") + .trim(); + return ( + normalized === "" || + normalized === "what's changed" || + normalized === "whats changed" || + normalized === "full changelog" || + normalized === "new contributors" || + normalized.startsWith("compare: ") || + normalized.includes("/compare/") + ); +} + +function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { + if (!note) return []; + + const items: string[] = []; + for (const rawLine of stripMarkup(note).split("\n")) { + const item = rawLine + .trim() + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .replace(/\s+/g, " "); + if (isIgnoredReleaseNoteLine(item)) continue; + items.push(truncateReleaseNoteItem(item)); + if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + } + return items; +} + +export function normalizeDesktopUpdateReleaseNotes( + releaseNotes: unknown, + fallbackVersion: string, +): ReadonlyArray { + const rawNotes = + typeof releaseNotes === "string" + ? [{ version: fallbackVersion, note: releaseNotes }] + : Array.isArray(releaseNotes) + ? releaseNotes.filter(isElectronReleaseNoteInfo) + : []; + + return rawNotes + .map((entry) => ({ + version: entry.version, + items: extractReleaseNoteItems(entry.note), + })) + .filter((entry) => entry.items.length > 0) + .slice(0, MAX_RELEASE_NOTE_GROUPS); +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 01233d7f7dfd5..e9256315377af 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -135,6 +135,12 @@ describe("updateMachine", () => { }); it("tracks available, download start, and progress cleanly", () => { + const releaseNotes = [ + { + version: "1.1.0", + items: ["feat: add update release notes"], + }, + ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -143,15 +149,33 @@ describe("updateMachine", () => { }, "1.1.0", "2026-03-04T00:00:00.000Z", + releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); + expect(available.releaseNotes).toBe(releaseNotes); + expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); + + it("clears release notes when checking again", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + status: "available", + availableVersion: "1.1.0-nightly.1", + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.releaseNotes).toEqual([]); + }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index ac51591ebd361..751e8d6e46079 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateReleaseNote, DesktopUpdateState, } from "@t3tools/contracts"; @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( ...state, status: "checking", checkedAt, + releaseNotes: [], message: null, downloadPercent: null, errorContext: null, @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( state: DesktopUpdateState, version: string, checkedAt: string, + releaseNotes: ReadonlyArray = [], ): DesktopUpdateState { return { ...state, status: "available", availableVersion: version, downloadedVersion: null, + releaseNotes, downloadPercent: null, checkedAt, message: null, @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( status: "up-to-date", availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt, message: null, diff --git a/apps/marketing/public/harnesses/cursor_light.svg b/apps/marketing/public/harnesses/cursor_light.svg index e61e0be3bfdd5..089d4676370b9 100644 --- a/apps/marketing/public/harnesses/cursor_light.svg +++ b/apps/marketing/public/harnesses/cursor_light.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 5ff5958c588f2..92e491a077a73 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -1,6 +1,6 @@ export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; export const MARKETING_STATS = { - githubStars: "12k+", + githubStars: "14k+", users: "100,000", } as const; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index a0cb3af49695e..945bef35341f8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -3,6 +3,7 @@ import type { ExpoConfig } from "expo/config"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; @@ -16,6 +17,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; + if ( isIosPersonalTeamBuild && (!personalTeamBundleIdentifier || @@ -27,20 +30,31 @@ if ( } const DEVELOPMENT_ASSETS = { - appIcon: "./assets/splash-icon-dev.png", - iosIcon: "./assets/icon-composer-dev.icon", - splashIcon: "./assets/splash-icon-dev.png", - androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png", + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), androidAdaptiveBackgroundColor: "#00639B", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", androidNotificationColor: "#00639B", } as const; +const PREVIEW_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveBackgroundColor: "#111533", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#7565C7", +} as const; + const RELEASE_ASSETS = { - appIcon: "./assets/splash-icon-prod.png", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), androidAdaptiveForeground: "./assets/android-icon-mark.png", androidAdaptiveBackgroundColor: "#000000", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -63,7 +77,7 @@ const VARIANT_CONFIG = { iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", - assets: RELEASE_ASSETS, + assets: PREVIEW_ASSETS, }, production: { appName: "T3 Code", diff --git a/apps/mobile/assets/android-icon-dev-foreground.png b/apps/mobile/assets/android-icon-dev-foreground.png deleted file mode 100644 index b33d7978d720f..0000000000000 Binary files a/apps/mobile/assets/android-icon-dev-foreground.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e83..0000000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png deleted file mode 100644 index de5a82d8c49e1..0000000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc230..0000000000000 --- a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/assets/splash-icon-dev.png b/apps/mobile/assets/splash-icon-dev.png deleted file mode 100644 index b33d6f337b0b6..0000000000000 Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-prod.png b/apps/mobile/assets/splash-icon-prod.png deleted file mode 100644 index 2ff311ce786f0..0000000000000 Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f193..9b887f58a28c2 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,6 @@ import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; +import { LogBox } from "react-native"; import { featureFlags } from "react-native-screens"; import App from "./src/App"; @@ -8,4 +9,8 @@ import App from "./src/App"; // native stack is rendered inside a non-fitToContents formSheet. featureFlags.experiment.synchronousScreenUpdatesEnabled = true; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + LogBox.ignoreAllLogs(); +} + registerRootComponent(App); diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 4d5f02aaa9ce6..b15a1cd442875 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -7,6 +7,32 @@ class T3NativeControlsModule : Module() { override fun definition() = ModuleDefinition { Name("T3NativeControls") + Function("getShowcasePairingUrl") { + appContext.currentActivity?.intent?.getStringExtra("showcasePairingUrl") + } + + Function("getShowcaseScene") { + val storedScene = appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-scene") + ?.takeIf { it.isFile } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } + storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") + } + + Function("prepareShowcaseCapture") { + // Android app data is cleared by the host runner before launch. + } + + Function("markShowcaseReady") { scene: String -> + appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-ready") + ?.writeText(scene) + } + View(T3HeaderButtonView::class) { Prop("label") { view: T3HeaderButtonView, label: String -> view.setLabel(label) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 33e1dc9086c4d..7781b164a2c8a 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,9 +1,48 @@ import ExpoModulesCore +import Security public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + Function("getShowcasePairingUrl") { + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("getShowcaseScene") { () -> String? in + let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" + if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { + return storedScene + } + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseScene"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("prepareShowcaseCapture") { + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + SecItemDelete([kSecClass as String: itemClass] as CFDictionary) + } + } + + Function("markShowcaseReady") { (scene: String) in + let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" + try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) + } + View(T3HeaderButtonView.self) { Prop("label") { (view: T3HeaderButtonView, label: String) in view.setLabel(label) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 44840eb570e63..1631c7fe68a14 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -30,6 +30,10 @@ class T3TerminalModule : Module() { view.focusRequest = focusRequest } + Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index fcc6092dbee5b..88de793a8f7dc 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -76,6 +76,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } + var autoFocus: Boolean = true + set(value) { + field = value + if (value) { + requestKeyboardFocus() + } else { + inputView.clearFocus() + hideKeyboard() + } + } + var backgroundColorHex: String = "#24292E" set(value) { field = value @@ -368,6 +379,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) } + private fun hideKeyboard() { + val inputMethodManager = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as? InputMethodManager + inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) + } + private fun applyTheme() { setBackgroundColor(backgroundColorValue) container.setBackgroundColor(backgroundColorValue) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 1c8978d4e5bfd..f68cc6b4a112d 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { view.focusRequest = focusRequest } + Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 191d08db28d3d..659837fc7abd7 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -248,6 +248,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var autoFocus = true { + didSet { + guard oldValue != autoFocus else { return } + if autoFocus { + requestKeyboardFocus() + } else { + inputField.resignFirstResponder() + } + } + } + var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { didSet { guard oldValue != appearanceScheme else { return } @@ -359,7 +370,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public override func didMoveToWindow() { super.didMoveToWindow() - guard window != nil else { return } + guard window != nil, autoFocus else { return } DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5c5046cc582bc..c99351547be2f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,8 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", + "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", + "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 1f198c33bf354..835f54491f631 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; @@ -21,6 +22,10 @@ import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + prepareNativeShowcaseCapture(); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // The Expo dev client launches the app via diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 9d66839e040bf..4eb4a5061e8f4 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -48,6 +48,7 @@ import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnv import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; +import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, @@ -313,6 +314,7 @@ function RootStackLayout(props: { return ( + {props.children} diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 4d1ac01b1ff4b..c8f1d43855172 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -1,13 +1,23 @@ -import { Image, View } from "react-native"; +import Constants from "expo-constants"; +import { Image } from "expo-image"; +import { View } from "react-native"; import { AppText as Text } from "./AppText"; -const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); +const appVariant = Constants.expoConfig?.extra?.appVariant; +const BRAND_MARK_SOURCE = + appVariant === "development" + ? require("../../../../assets/dev/blueprint-ios-1024.png") + : appVariant === "preview" + ? require("../../../../assets/nightly/nightly-ios-1024.png") + : require("../../../../assets/prod/black-ios-1024.png"); +const DEFAULT_STAGE_LABEL = + appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha"; export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { const compact = props.compact ?? false; const iconSize = compact ? 32 : 44; - const stageLabel = props.stageLabel ?? "Alpha"; + const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL; return ( diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 400c4e5e3d36f..608d43b2acb8a 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -33,6 +33,8 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect export function CloudEnvironmentRows(props: { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + readonly showcaseAvailableEnvironments?: ReadonlyArray; + readonly showcaseSignedIn?: boolean; /** * Hide the "T3 Connect" section title + refresh button for hosts that * provide their own chrome (the onboarding sheet's native header and @@ -43,7 +45,8 @@ export function CloudEnvironmentRows(props: { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = controller.availableRelayEnvironments; + const availableCloudEnvironments = + props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -64,7 +67,7 @@ export function CloudEnvironmentRows(props: { const showHeader = props.showHeader ?? true; - if (!isSignedIn) return null; + if (!(props.showcaseSignedIn ?? isSignedIn)) return null; return ( diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 0409655583b7f..438b50a27ee61 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -96,5 +96,22 @@ describe("isPendingNativeViewRegistration", () => { new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), ), ).toBe(false); + expect( + isPendingNativeViewRegistration( + new Error( + "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150", + ), + ), + ).toBe(true); + }); +}); + +describe("isNativeReviewDiffDrawEvent", () => { + it("accepts only native events emitted after diff rows draw", async () => { + const { isNativeReviewDiffDrawEvent } = await import("./nativeReviewDiffSurface"); + + expect(isNativeReviewDiffDrawEvent({ message: "draw-metrics" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "visible-range" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "rows-decoded" })).toBe(false); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 551e577b294b1..fda7341066873 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -149,6 +149,10 @@ export interface NativeReviewDiffViewHandle { readonly scrollToTop: (animated?: boolean) => Promise; } +export function isNativeReviewDiffDrawEvent(payload: Readonly>): boolean { + return payload.message === "draw-metrics" || payload.message === "visible-range"; +} + interface NativeReviewDiffViewRef { readonly setRowsJson: (rowsJson: string) => Promise; readonly setTokensJson: (tokensJson: string) => Promise; @@ -170,9 +174,11 @@ let nativeReviewDiffViewResolutionFailed = false; type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; export function isPendingNativeViewRegistration(error: unknown): boolean { + if (!(error instanceof Error)) return false; return ( - error instanceof Error && - error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) + error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) || + (error.message.includes("Unable to find the class") && + error.message.includes("T3ReviewDiffView view with tag")) ); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0807304631d88..456afc438d9be 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -167,7 +167,6 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index c34516afdd6d6..ad5aa7d9aa525 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -57,6 +57,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { AndroidThreadGitMenu, ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; import { + isNativeReviewDiffDrawEvent, type NativeReviewDiffViewHandle, resolveNativeReviewDiffView, } from "../diffs/nativeReviewDiffSurface"; @@ -72,8 +73,10 @@ import { resolveReviewAvailability } from "./reviewAvailability"; import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; import { buildReviewSectionMenu } from "./review-section-menu"; import type { ReviewSectionItem } from "./reviewModel"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; const REVIEW_HEADER_SPACING = 0; +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( @@ -395,6 +398,7 @@ export function ReviewSheet(props: ReviewSheetProps) { }); const NativeReviewDiffView = resolveNativeReviewDiffView()!; const nativeReviewDiffViewRef = useRef(null); + const showcasedReviewDrawRef = useRef(null); // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { @@ -436,6 +440,25 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedRowIds: commentSelection.selectedRowIds, canHighlight: parsedDiff.kind === "files", }); + const showcaseReviewKey = + SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection + ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}` + : null; + const handleNativeDebug = useCallback( + (event: NativeSyntheticEvent>) => { + nativeBridge.onDebug(event); + if ( + showcaseReviewKey === null || + showcasedReviewDrawRef.current === showcaseReviewKey || + !isNativeReviewDiffDrawEvent(event.nativeEvent) + ) { + return; + } + showcasedReviewDrawRef.current = showcaseReviewKey; + markNativeShowcaseReady("review"); + }, + [nativeBridge.onDebug, showcaseReviewKey], + ); const handleSelectFile = useCallback( (fileId: string | null) => { @@ -802,7 +825,7 @@ export function ReviewSheet(props: ReviewSheetProps) { tokensPatchJson={nativeBridge.tokensPatchJson} tokensResetKey={nativeBridge.tokensResetKey} viewedFileIdsJson={nativeBridge.viewedFileIdsJson} - onDebug={nativeBridge.onDebug} + onDebug={handleNativeDebug} onPressLine={commentSelection.onPressLine} onVisibleFileChange={handleVisibleFileChange} onToggleComment={nativeBridge.onToggleComment} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index ccf049e0c21d2..93b806f6487bc 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -15,6 +15,15 @@ import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, + SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, + SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, +} from "../showcase/showcaseEnvironmentRows"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; export function SettingsEnvironmentsRouteScreen() { const { @@ -25,18 +34,56 @@ export function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + const environmentSections = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); + const localEnvironments = SHOWCASE_ENABLED + ? applyShowcaseLocalEnvironmentDisplayUrls(environmentSections.localEnvironments) + : environmentSections.localEnvironments; + const connectedCloudEnvironments = SHOWCASE_ENABLED + ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS + : environmentSections.connectedCloudEnvironments; const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); const headerIconColor = useThemeColor("--color-icon"); + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); + return () => clearTimeout(timer); + }, []); + const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); + const handleUpdateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => { + if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates); + const actualEnvironment = environmentSections.localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + const presentedEnvironment = localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + return onUpdateEnvironment(environmentId, { + ...updates, + displayUrl: + actualEnvironment && presentedEnvironment + ? resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: actualEnvironment.displayUrl, + presentedDisplayUrl: presentedEnvironment.displayUrl, + submittedDisplayUrl: updates.displayUrl, + }) + : updates.displayUrl, + }); + }, + [environmentSections.localEnvironments, localEnvironments, onUpdateEnvironment], + ); return ( @@ -92,7 +139,7 @@ export function SettingsEnvironmentsRouteScreen() { onToggle={() => handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} - onUpdate={onUpdateEnvironment} + onUpdate={handleUpdateEnvironment} /> ))} @@ -114,10 +161,16 @@ export function SettingsEnvironmentsRouteScreen() { )} - {hasCloudPublicConfig() ? ( + {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( ) : null} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx new file mode 100644 index 0000000000000..9a4272824bae6 --- /dev/null +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import { Keyboard, View } from "react-native"; +import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { useConnectionController } from "../connection/useConnectionController"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; +import { useWorkspaceState } from "../../state/workspace"; +import { + getNativeShowcasePairingUrls, + getNativeShowcaseScene, + markNativeShowcaseReady, + type ShowcaseScene, +} from "./nativeShowcaseScene"; +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; +import { retryShowcaseOperation } from "./showcaseRetry"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; +const SHOWCASE_THREAD_ID = "remote-command-center"; + +function sceneFromPathname(pathname: string): ShowcaseScene | null { + const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; + if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { + return "environments"; + } + if (routePath.endsWith("/terminal")) return "terminal"; + if (routePath.endsWith("/review")) return "review"; + if (routePath.startsWith("/threads/")) return "thread"; + if (routePath === "/") return "threads"; + return null; +} + +export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { + const navigation = useNavigation(); + const { connectPairingUrl } = useConnectionController(); + const workspace = useWorkspaceState(); + const projects = useProjects(); + const threads = useThreadShells(); + const attemptedPairingRef = useRef(new Set()); + const seededPendingTaskIdsRef = useRef(new Set()); + const [pairingUrls, setPairingUrls] = useState>([]); + const [pendingTasksReady, setPendingTasksReady] = useState(false); + const [requestedScene, setRequestedScene] = useState(null); + const [readyScene, setReadyScene] = useState(null); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; + + const readPairingUrls = () => { + const values = getNativeShowcasePairingUrls(); + if (values.length > 0) setPairingUrls(values); + }; + readPairingUrls(); + const interval = setInterval(readPairingUrls, 250); + return () => clearInterval(interval); + }, [pairingUrls.length]); + + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + + const readRequestedScene = () => { + const value = getNativeShowcaseScene(); + if (value) setRequestedScene(value); + }; + readRequestedScene(); + const interval = setInterval(readRequestedScene, 250); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; + let cancelled = false; + void (async () => { + await Promise.all( + pairingUrls.map(async (pairingUrl) => { + if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return; + const paired = await retryShowcaseOperation( + async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)), + { isCancelled: () => cancelled }, + ); + if (paired) attemptedPairingRef.current.add(pairingUrl); + }), + ); + })(); + return () => { + cancelled = true; + }; + }, [connectPairingUrl, pairingUrls]); + + const scene = sceneFromPathname(props.pathname); + const hasServerFixture = + workspace.state.hasReadyEnvironment && + workspace.environments.length >= 3 && + projects.length >= 3 && + threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const hasFixture = hasServerFixture && pendingTasksReady; + const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + + useEffect(() => { + if (!SHOWCASE_ENABLED || !hasServerFixture || pendingTasksReady) return; + + const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); + if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + + let cancelled = false; + for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); + void (async () => { + const results = await Promise.all( + pendingTasks.map(async (task) => { + const messageId = String(task.messageId); + if (seededPendingTaskIdsRef.current.has(messageId)) return true; + const seeded = await retryShowcaseOperation( + async () => { + await enqueueThreadOutboxMessage(task); + return true; + }, + { isCancelled: () => cancelled }, + ); + if (seeded) seededPendingTaskIdsRef.current.add(messageId); + return seeded; + }), + ); + if (!cancelled && results.every(Boolean)) setPendingTasksReady(true); + })(); + return () => { + cancelled = true; + }; + }, [hasServerFixture, pendingTasksReady, projects]); + + useEffect(() => { + if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return; + if (scene === requestedScene) return; + + const params = { + environmentId: String(showcaseThread.environmentId), + threadId: SHOWCASE_THREAD_ID, + }; + if (requestedScene === "threads") { + navigation.dispatch(StackActions.popToTop()); + return; + } + const routes: Array<{ + name: string; + params?: Record; + state?: { index: number; routes: Array<{ name: string }> }; + }> = [{ name: "Home" }]; + if (requestedScene === "environments") { + routes.push({ + name: "SettingsSheet", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }); + } else { + routes.push({ name: "Thread", params }); + if (requestedScene === "terminal") { + routes.push({ + name: "ThreadTerminal", + params: { ...params, terminalId: "term-1" }, + }); + } else if (requestedScene === "review") { + routes.push({ name: "ThreadReview", params }); + } + } + navigation.dispatch( + CommonActions.reset({ + index: routes.length - 1, + routes, + }), + ); + }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + + useEffect(() => { + if ( + !SHOWCASE_ENABLED || + scene === null || + requestedScene === null || + scene !== requestedScene || + !hasFixture + ) { + setReadyScene(null); + return; + } + // Review owns its readiness marker because route activation happens before + // the VCS request is parsed and the native diff surface is mounted. + if (scene === "review") { + setReadyScene(null); + return; + } + if (scene === "terminal") Keyboard.dismiss(); + + let renderFrame: number | null = null; + let readyFrame: number | null = null; + const settleTimer = setTimeout(() => { + renderFrame = requestAnimationFrame(() => { + readyFrame = requestAnimationFrame(() => { + markNativeShowcaseReady(scene); + setReadyScene(scene); + }); + }); + }, 500); + return () => { + clearTimeout(settleTimer); + if (renderFrame !== null) cancelAnimationFrame(renderFrame); + if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }; + }, [hasFixture, requestedScene, scene]); + + if (!SHOWCASE_ENABLED || readyScene === null) return null; + + return ( + + ); +} diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts new file mode 100644 index 0000000000000..1f2e263ebf1bf --- /dev/null +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; + +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; + +interface NativeShowcaseControls { + readonly getShowcasePairingUrl?: () => string | null; + readonly getShowcaseScene?: () => string | null; + readonly prepareShowcaseCapture?: () => void; + readonly markShowcaseReady?: (scene: ShowcaseScene) => void; +} + +function nativeShowcaseControls(): NativeShowcaseControls | null { + return requireOptionalNativeModule("T3NativeControls"); +} + +export function getNativeShowcasePairingUrls(): ReadonlyArray { + try { + let raw = nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim(); + if (!raw) return []; + if (raw.startsWith("json-uri:")) { + try { + raw = decodeURIComponent(raw.slice("json-uri:".length)); + } catch { + return []; + } + } + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0, + ); + } + } catch { + // Older runners pass a single URL rather than a JSON array. + } + return [raw]; + } catch { + return []; + } +} + +export function getNativeShowcaseScene(): ShowcaseScene | null { + try { + const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim(); + return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null; + } catch { + return null; + } +} + +export function prepareNativeShowcaseCapture(): void { + try { + nativeShowcaseControls()?.prepareShowcaseCapture?.(); + } catch { + // The harness still works when a development build predates this helper. + } +} + +export function markNativeShowcaseReady(scene: ShowcaseScene): void { + try { + nativeShowcaseControls()?.markShowcaseReady?.(scene); + } catch { + // The readiness marker is capture-runner metadata, never app functionality. + } +} diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts new file mode 100644 index 0000000000000..a459cbe1db398 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, +} from "./showcaseEnvironmentRows"; + +function environment( + environmentId: string, + environmentLabel: string, + displayUrl = "http://127.0.0.1:3773/", +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(environmentId), + environmentLabel, + displayUrl, + isRelayManaged: false, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }; +} + +it("presents showcase transports as remote endpoints", () => { + const environments = applyShowcaseLocalEnvironmentDisplayUrls([ + environment("runtime-id-1", "Moonbase Terminal"), + environment("runtime-id-2", "Suspense Station"), + environment("runtime-id-3", "Kernel Cabin"), + ]); + + assert.deepStrictEqual( + environments.map(({ displayUrl }) => displayUrl), + [ + "https://moonbase.tail9f3a.ts.net/", + "https://suspense-vps.hel1.t3.sh/", + "http://100.82.16.5:3773/", + ], + ); +}); + +it("leaves environments outside the showcase fixture unchanged", () => { + const original = environment( + "runtime-id-4", + "My Workstation", + "https://workstation.example.test/", + ); + + assert.deepStrictEqual(applyShowcaseLocalEnvironmentDisplayUrls([original]), [original]); +}); + +it("does not persist a cosmetic showcase URL when only the label is saved", () => { + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + }), + "http://127.0.0.1:3773/", + ); + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://new-host.example.com/", + }), + "https://new-host.example.com/", + ); +}); diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts new file mode 100644 index 0000000000000..66ec3046c67c5 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; + +import type { RelayEnvironmentView } from "../connection/useConnectionController"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +const SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS: Readonly> = { + "Moonbase Terminal": "https://moonbase.tail9f3a.ts.net/", + "Suspense Station": "https://suspense-vps.hel1.t3.sh/", + "Kernel Cabin": "http://100.82.16.5:3773/", +}; + +export function applyShowcaseLocalEnvironmentDisplayUrls( + environments: ReadonlyArray, +): ReadonlyArray { + return environments.map((environment) => ({ + ...environment, + displayUrl: + SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS[environment.environmentLabel] ?? + environment.displayUrl, + })); +} + +export function resolveShowcaseEnvironmentUpdateDisplayUrl(input: { + readonly actualDisplayUrl: string; + readonly presentedDisplayUrl: string; + readonly submittedDisplayUrl: string; +}): string { + return input.submittedDisplayUrl === input.presentedDisplayUrl + ? input.actualDisplayUrl + : input.submittedDisplayUrl; +} + +const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); +const pocketPiEndpoint = { + httpBaseUrl: "https://pocket-pi.t3.sh", + wsBaseUrl: "wss://pocket-pi.t3.sh", + providerKind: "t3_relay" as const, +}; + +export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("showcase-aurora-gpu"), + environmentLabel: "Aurora GPU Pod", + displayUrl: "https://aurora-gpu.t3.sh", + isRelayManaged: true, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }, +]; + +export const SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environment: { + environmentId: pocketPiId, + label: "Pocket Pi", + endpoint: pocketPiEndpoint, + linkedAt: "2026-07-16T08:00:00.000Z", + }, + availability: "online", + status: { + environmentId: pocketPiId, + endpoint: pocketPiEndpoint, + status: "online", + checkedAt: "2026-07-16T08:41:00.000Z", + }, + error: null, + traceId: null, + }, +]; diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts new file mode 100644 index 0000000000000..2a1f18159ba6a --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -0,0 +1,74 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; + +const projects: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("moonbase-terminal"), + id: ProjectId.make("t3code"), + title: "T3 Code", + workspaceRoot: "/workspace/t3code", + dataAudience: "private", + repositoryIdentity: null, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("suspense-station"), + id: ProjectId.make("react"), + title: "React", + workspaceRoot: "/workspace/react", + dataAudience: "private", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, +]; + +it("builds sendable-looking pending tasks against real showcase projects", () => { + const tasks = buildShowcasePendingTasks(projects, Date.parse("2026-07-16T09:00:00.000Z")); + + assert.equal(tasks.length, SHOWCASE_PENDING_TASK_DEFINITIONS.length); + assert.deepStrictEqual( + tasks.map((task) => ({ + environmentId: String(task.environmentId), + projectId: task.creation ? String(task.creation.projectId) : undefined, + title: task.creation?.projectTitle, + branch: task.creation?.branch, + createdAt: task.createdAt, + })), + [ + { + environmentId: "moonbase-terminal", + projectId: "t3code", + title: "T3 Code", + branch: "feat/offline-launchpad", + createdAt: "2026-07-16T08:52:00.000Z", + }, + { + environmentId: "suspense-station", + projectId: "react", + title: "React", + branch: "perf/tunnel-handoff", + createdAt: "2026-07-16T08:33:00.000Z", + }, + ], + ); + assert.equal( + tasks.every((task) => task.modelSelection !== undefined), + true, + ); +}); + +it("waits until every referenced project has hydrated", () => { + assert.equal(buildShowcasePendingTasks(projects.slice(0, 1), Date.now()).length, 1); +}); diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.ts new file mode 100644 index 0000000000000..1b0b5ed22f433 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.ts @@ -0,0 +1,66 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; + +export const SHOWCASE_PENDING_TASK_DEFINITIONS = [ + { + projectId: "t3code", + id: "offline-launch-checklist", + text: "Ship the offline launch checklist before touchdown ✈️", + branch: "feat/offline-launchpad", + minutesAgo: 8, + }, + { + projectId: "react", + id: "train-tunnel-suspense", + text: "Polish the Suspense handoff for the train tunnel 🚇", + branch: "perf/tunnel-handoff", + minutesAgo: 27, + }, +] as const; + +const FALLBACK_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} as const; + +export function buildShowcasePendingTasks( + projects: ReadonlyArray, + now: number, +): ReadonlyArray { + return SHOWCASE_PENDING_TASK_DEFINITIONS.flatMap((definition) => { + const project = projects.find((candidate) => String(candidate.id) === definition.projectId); + if (!project) return []; + + return [ + { + environmentId: project.environmentId, + threadId: ThreadId.make(`showcase-pending-${definition.id}`), + messageId: MessageId.make(`showcase-pending-message-${definition.id}`), + commandId: CommandId.make(`showcase-pending-command-${definition.id}`), + text: definition.text, + attachments: [], + modelSelection: project.defaultModelSelection ?? FALLBACK_MODEL_SELECTION, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + creation: { + projectId: project.id, + projectTitle: project.title, + projectCwd: project.workspaceRoot, + workspaceMode: "local" as const, + branch: definition.branch, + worktreePath: project.workspaceRoot, + }, + createdAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + }, + ]; + }); +} diff --git a/apps/mobile/src/features/showcase/showcaseRetry.test.ts b/apps/mobile/src/features/showcase/showcaseRetry.test.ts new file mode 100644 index 0000000000000..86c2890366eee --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; + +import { retryShowcaseOperation } from "./showcaseRetry"; + +it("retries a failed showcase operation until it succeeds", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + async () => { + attempts += 1; + return attempts === 3; + }, + { isCancelled: () => false, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 3); +}); + +it("recovers when a showcase operation attempt hangs", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + () => { + attempts += 1; + return attempts === 1 ? new Promise(() => undefined) : Promise.resolve(true); + }, + { isCancelled: () => false, attemptTimeoutMs: 1, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 2); +}); + +it("stops retrying when the owning showcase effect is cancelled", async () => { + let cancelled = false; + const succeeded = await retryShowcaseOperation( + async () => { + cancelled = true; + return false; + }, + { isCancelled: () => cancelled, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, false); +}); diff --git a/apps/mobile/src/features/showcase/showcaseRetry.ts b/apps/mobile/src/features/showcase/showcaseRetry.ts new file mode 100644 index 0000000000000..0c7aeb120198c --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.ts @@ -0,0 +1,46 @@ +export interface ShowcaseRetryOptions { + readonly isCancelled: () => boolean; + readonly attemptTimeoutMs?: number; + readonly retryDelayMs?: number; +} + +const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000; +const DEFAULT_RETRY_DELAY_MS = 500; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runAttemptWithTimeout( + operation: () => Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } catch { + return false; + } finally { + if (timeout !== null) clearTimeout(timeout); + } +} + +/** Retry transient showcase setup work until it succeeds or the owning effect unmounts. */ +export async function retryShowcaseOperation( + operation: () => Promise, + options: ShowcaseRetryOptions, +): Promise { + const attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + + while (!options.isCancelled()) { + if (await runAttemptWithTimeout(operation, attemptTimeoutMs)) return true; + if (!options.isCancelled()) await delay(retryDelayMs); + } + return false; +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 69037514d0da9..b205b4df72cc2 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -37,6 +37,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly buffer: string; readonly fontSize?: number; readonly isRunning: boolean; + readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; @@ -215,6 +216,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf candidate.messageId !== message.messageId), + message, + ]); }); // Rewrites an already-queued message. A no-op when the message has been diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 2ccaa471c87d6..6c665c432f46d 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -294,6 +294,31 @@ describe("thread outbox", () => { registry.dispose(); }); + it("replaces an existing message when an enqueue retry uses the same id", async () => { + const registry = AtomRegistry.make(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const retried = { ...message, text: "retried" }; + + await manager.enqueue(message); + await manager.enqueue(retried); + + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [retried], + }); + registry.dispose(); + }); + it("updates a queued message in place but never resurrects a removed one", async () => { const registry = AtomRegistry.make(); const stored = new Map(); diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index b36c2b2d496f0..7e4e88aeb2b43 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -62,7 +62,7 @@ const promptText = NodeProcess.argv[4] ?? "helo"; const targetReasoning = NodeProcess.env.CURSOR_REASONING ?? ""; const targetContext = NodeProcess.env.CURSOR_CONTEXT ?? ""; const targetFast = NodeProcess.env.CURSOR_FAST ?? ""; -const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "agent"; +const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "cursor-agent"; const promptWaitMs = Number(NodeProcess.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); const requestTimeoutMs = Number(NodeProcess.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index cc1cc2ff776bc..e87e4dcbd0dc3 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -32,6 +32,63 @@ describe("imageMime", () => { }); }); + it("rejects payload with characters outside the base64 alphabet", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull(); + }); + + it("rejects structurally malformed base64", () => { + // '=' before the trailing padding position + expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); + // more than two padding characters + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull(); + // length not a multiple of 4 + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull(); + }); + + it("accepts base64 with one or two trailing padding characters", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVsbA==")).toEqual({ + mimeType: "image/png", + base64: "SGVsbA==", + }); + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8h")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8h", + }); + }); + + it("rejects empty and whitespace-only payloads", () => { + expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull(); + }); + + it("parses a case-insensitive scheme and mime type", () => { + expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("parses a multi-megabyte payload from a deep call stack", () => { + // Regression: matching the payload with a regex borrowed the JS call + // stack, so a ~10 MB image parsed inside fiber execution threw + // "RangeError: Maximum call stack size exceeded". + const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; + const atDepth = (depth: number): ReturnType => + depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); + const findMaxDepth = (depth: number): number => { + try { + return findMaxDepth(depth + 1); + } catch { + return depth; + } + }; + const result = atDepth(Math.floor(findMaxDepth(0) * 0.85)); + expect(result?.mimeType).toBe("image/png"); + expect(result?.base64.length).toBe(14_000_000); + }); + it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abea7..66ce6096e853f 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +// Whether `code` is a character the base64 payload may contain, aside from +// the whitespace handled separately below. +function isBase64Char(code: number): boolean { + return ( + (code >= 0x61 && code <= 0x7a) || // a-z + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x30 && code <= 0x39) || // 0-9 + code === 0x2b || // + + code === 0x2f || // / + code === 0x3d // = + ); +} + +function isBase64Whitespace(code: number): boolean { + return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space +} + +// Data URLs carry the full image payload, so this parser must never run a +// regex across the payload: V8's regex engine borrows the JS call stack, and +// matching a multi-megabyte string from a deep call stack (e.g. inside fiber +// execution) throws "Maximum call stack size exceeded". export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { - const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); - if (!match) return null; + const trimmed = dataUrl.trim(); + if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null; + + const commaIndex = trimmed.indexOf(","); + if (commaIndex === -1) return null; + const header = trimmed.slice(5, commaIndex); + if (header.length === 0) return null; const headerParts: Array = []; - for (const part of (match[1] ?? "").split(";")) { - const trimmed = part.trim(); - if (trimmed.length > 0) { - headerParts.push(trimmed); + for (const part of header.split(";")) { + const partTrimmed = part.trim(); + if (partTrimmed.length > 0) { + headerParts.push(partTrimmed); } } if (headerParts.length < 2) { @@ -51,8 +77,37 @@ export function parseBase64DataUrl( } const mimeType = headerParts[0]?.toLowerCase(); - const base64 = match[2]?.replace(/\s+/g, ""); - if (!mimeType || !base64) return null; + if (!mimeType) return null; + + const payload = trimmed.slice(commaIndex + 1); + const runs: Array = []; + let runStart = -1; + for (let index = 0; index < payload.length; index += 1) { + const code = payload.charCodeAt(index); + if (isBase64Char(code)) { + if (runStart === -1) runStart = index; + continue; + } + if (!isBase64Whitespace(code)) return null; + if (runStart !== -1) { + runs.push(payload.slice(runStart, index)); + runStart = -1; + } + } + if (runStart !== -1) { + runs.push(payload.slice(runStart)); + } + const base64 = runs.length === 1 ? runs[0]! : runs.join(""); + if (base64.length === 0 || base64.length % 4 !== 0) return null; + const firstPad = base64.indexOf("="); + if (firstPad !== -1) { + // '=' is only valid as one or two trailing padding characters; Node's + // decoder would otherwise silently truncate at the first '='. + if (base64.length - firstPad > 2) return null; + for (let index = firstPad; index < base64.length; index += 1) { + if (base64.charCodeAt(index) !== 0x3d) return null; + } + } return { mimeType, base64 }; } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index a51ad20afbe89..2eef6ac841671 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -411,6 +411,28 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("replacing with a rule that already exists elsewhere does not duplicate it", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+alt+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "mod+r", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+alt+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 5ddae4943f8c7..18a78fe362328 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -644,7 +644,9 @@ const make = Effect.gen(function* () { const nextConfig = [ ...customConfig.filter((entry) => { if (replaceTarget) { - return !isSameKeybindingRule(entry, replaceTarget); + return ( + !isSameKeybindingRule(entry, replaceTarget) && !isSameKeybindingRule(entry, rule) + ); } return !isSameKeybindingRule(entry, rule); }), diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index a3475d2f190bd..2ccdd862522fb 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -1,7 +1,9 @@ +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -280,18 +282,30 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((rows) => + // Skip rows that no longer decode (e.g. written by an older build) + // instead of failing the whole list — one stale row must not disable + // every consumer that enumerates sessions, such as the reaper. Effect.forEach(rows, (row) => decodeRuntimeRow(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:decodeRows", - cause, - { threadId: row.threadId }, - ), + Effect.map(Option.some), + Effect.catch((cause) => + Effect.logWarning("provider.session.runtime.row-skipped", { + threadId: row.threadId, + error: PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ).message, + }).pipe(Effect.as(Option.none())), ), ), ), ), + Effect.map((decoded) => + Arr.filterMap(decoded, (row) => + Option.isSome(row) ? Result.succeed(row.value) : Result.failVoid, + ), + ), ); const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts index ce77760be1ec7..c4333ce144ae2 100644 --- a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -187,7 +187,7 @@ describe("persistence error correlation", () => { }).pipe(Effect.provide(authPairingLinkLayer)), ); - it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + it.effect("skips undecodable provider runtime rows and correlates SQL failures by thread", () => Effect.gen(function* () { const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const sql = yield* SqlClient.SqlClient; @@ -220,16 +220,24 @@ describe("persistence error correlation", () => { ) `; - const decodeError = yield* Effect.flip(runtimes.list()); - assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); - assert.deepStrictEqual(decodeError.correlation, { threadId }); - assert.equal( - decodeError.message, - `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + const validThreadId = ThreadId.make("thread-valid"); + yield* runtimes.upsert({ + threadId: validThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: null, + }); + + const listed = yield* runtimes.list(); + assert.deepStrictEqual( + listed.map((runtime) => runtime.threadId), + [validThreadId], ); - assert.notInclude(decodeError.issue, runtimePayload); - assert.notInclude(decodeError.message, runtimePayload); - assert.notInclude(decodeError.message, lastSeenAt); yield* sql`DROP TABLE provider_session_runtime`; const sqlFailure = yield* Effect.flip( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 380222e8d78e7..42f4648e456d0 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,5 @@ +import type { ProviderInteractionMode } from "@t3tools/contracts"; + const T3_CODE_THREAD_TOOL_INSTRUCTIONS = ` ## T3 Code child threads @@ -143,3 +145,26 @@ The \`request_user_input\` tool is unavailable in Default mode. If you call it w In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${T3_CODE_THREAD_TOOL_INSTRUCTIONS} `; + +export interface CodexRuntimeInfo { + readonly model: string; + readonly reasoningEffort: string; +} + +// Values come from trusted config, but keep the block single-line regardless. +function toSingleLine(value: string): string { + return value.replaceAll(/\s+/g, " ").trim(); +} + +export function buildCodexDeveloperInstructions( + interactionMode: ProviderInteractionMode, + runtime: CodexRuntimeInfo, +): string { + const base = + interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; + return `${base} + +In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.`; +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a28202..ee9cddf949cff 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -119,6 +119,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -154,11 +155,11 @@ export const ClaudeDriver: ProviderDriver = { capacity: 1, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv).pipe( + probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); const checkProvider = checkClaudeProviderStatus( effectiveConfig, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 87298f80125eb..a7ff41fc89ea8 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -31,14 +31,23 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); - expect((yield* makeClaudeEnvironment({ homePath })).HOME).toBe(resolved); + expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${resolved}`, + `claude\0${resolved}\0`, ); }), ); + it.effect("separates capability probes by cwd", () => + Effect.gen(function* () { + const config = { binaryPath: "claude", homePath: "" }; + const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); + const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); + expect(first).not.toBe(second); + }), + ); + it.effect("keeps continuation compatible across instances with the same Claude HOME", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 65c74f9764a59..b3ef22b640cf7 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -24,7 +24,13 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, - HOME: resolvedHomePath, + // Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME. + // Overriding HOME also relocates the macOS login keychain lookup + // ($HOME/Library/Keychains), so the spawned CLI can't find its stored + // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points + // Claude Code at its config dir directly while leaving HOME (and the + // keychain) intact. + CLAUDE_CONFIG_DIR: resolvedHomePath, }; }); @@ -38,8 +44,9 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( function* ( config: Pick, + cwd?: string, ): Effect.fn.Return { const resolvedHomePath = yield* resolveClaudeHomePath(config); - return `${config.binaryPath}\0${resolvedHomePath}`; + return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; }, ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index ec78b1665ef58..03c717abb14d9 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -114,6 +114,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const mcpOauthLocksTarget = yield* fileSystem.readLink( + path.join(shadowHome, "mcp-oauth-locks"), + ); const modelsCacheExists = yield* fileSystem.exists( path.join(shadowHome, "models_cache.json"), ); @@ -124,12 +127,45 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); }), ); + it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); + const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); + + yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const locksTarget = yield* fileSystem.readLink(shadowLocks); + const sharedLockExists = yield* fileSystem.exists( + path.join(sharedLocks, "file-store.lock"), + ); + + expect(locksTarget).toBe(sharedLocks); + expect(sharedLockExists).toBe(true); + }), + ); + it.effect("accepts Codex-created shadow-local runtime directories", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index d2d09e9d84409..92e923c0b6695 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -26,10 +26,12 @@ const KNOWN_SHARED_DIRECTORIES = [ "plugins", "cache", "logs", + "mcp-oauth-locks", ] as const; const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); +const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = @@ -225,16 +227,6 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu linkPath: link, }); - if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, - }); - } - const createLink = input.fileSystem.symlink(target, link).pipe( Effect.catchTags({ PlatformError: (cause) => @@ -250,6 +242,33 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu }), ); + if (state._tag === "NotSymlink") { + if (!REPLACEABLE_SHARED_RUNTIME_DIRECTORIES.has(input.entryName)) { + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, + }); + } + + yield* input.fileSystem.remove(link, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + return yield* createLink; + } + if (state._tag === "Missing") { return yield* createLink; } diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index c394a7d1b4395..61f80489774aa 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -1,5 +1,5 @@ /** - * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime. + * CursorDriver — `ProviderDriver` for the Cursor Agent (`cursor-agent`) runtime. * * Cursor exposes an ACP-based CLI. Model catalog and capability refreshes * happen during the managed provider status check via Cursor's @@ -42,7 +42,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceCapabilities, - makeStaticProviderMaintenanceResolver, + type ProviderMaintenanceCapabilitiesResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -54,15 +54,16 @@ const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); -const UPDATE = makeStaticProviderMaintenanceResolver( - makeProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, - updateExecutable: "agent", - updateArgs: ["update"], - updateLockKey: "cursor-agent", - }), -); +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (options) => + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: options?.binaryPath?.trim() || "cursor-agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }), +}; export type CursorDriverEnv = | ChildProcessSpawner.ChildProcessSpawner @@ -100,6 +101,7 @@ export const CursorDriver: ProviderDriver = { defaultConfig: (): CursorSettings => decodeCursorSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -132,6 +134,7 @@ export const CursorDriver: ProviderDriver = { const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index d855d1a45151a..4eb32c20c4722 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -84,6 +84,7 @@ export const GrokDriver: ProviderDriver = { defaultConfig: (): GrokSettings => decodeGrokSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; @@ -114,6 +115,7 @@ export const GrokDriver: ProviderDriver = { const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 25a521245c84c..afd9c2a9bb96b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -421,7 +421,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { + it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => { const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -436,7 +436,10 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.env?.HOME, NodePath.join(NodeOS.homedir(), ".claude-work")); + assert.equal( + createInput?.options.env?.CLAUDE_CONFIG_DIR, + NodePath.join(NodeOS.homedir(), ".claude-work"), + ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de88..af8f5d6704b2e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -583,6 +583,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -602,6 +603,7 @@ const probeClaudeCapabilities = ( settingSources: ["user", "project", "local"], allowedTools: [], env: claudeEnvironment, + ...(cwd ? { cwd } : {}), stderr: () => {}, }, }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 2ce042aceeda9..9faade3158f87 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -4,11 +4,12 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import { + buildCodexDeveloperInstructions, CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; @@ -118,7 +119,10 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); @@ -163,12 +167,31 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); }); + it("reports the same fallback model and effort in settings and instructions", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + }), + ); + + const settings = params.collaborationMode?.settings; + NodeAssert.equal(settings?.model, DEFAULT_MODEL); + NodeAssert.equal(settings?.reasoning_effort, "medium"); + NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + }); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -194,6 +217,53 @@ describe("buildTurnStartParams", () => { }); }); +describe("buildCodexDeveloperInstructions", () => { + it("appends runtime info after the mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /T3 Code/); + NodeAssert.match(instructions, /Codex harness/); + NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); + }); + + it("includes runtime info alongside plan mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + }); + + it("varies with the model and effort of each turn", () => { + const first = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + const second = buildCodexDeveloperInstructions("default", { + model: "gpt-5.4", + reasoningEffort: "high", + }); + + NodeAssert.notEqual(first, second); + }); + + it("flattens multiline metadata into single-line runtime info", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt\n5.3\ncodex", + reasoningEffort: " high\neffort ", + }); + + NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + }); +}); + describe("T3 tool developer instructions", () => { it("documents child thread creation without removed preview tools", () => { for (const instructions of [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index d356eb7747a6c..a67563efa0da4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -37,10 +37,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import { - CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -332,15 +329,16 @@ function buildCodexCollaborationMode(input: { return undefined; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; + const reasoningEffort = input.effort ?? "medium"; return { mode: input.interactionMode, settings: { model, - reasoning_effort: input.effort ?? "medium", - developer_instructions: - input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + reasoning_effort: reasoningEffort, + developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, { + model, + reasoningEffort, + }), }, }; } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 7aa5ab480e627..9669f0fa9b396 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -232,7 +232,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); - assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/); + assert.match(String(delta.itemId), /^assistant:mock-session-1:runtime:[^:]+:segment:0$/); } const assistantCompleted = runtimeEvents.find( @@ -752,14 +752,20 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.isDefined(assistantStarted); if (assistantStarted?.type === "item.started") { assert.equal(String(assistantStarted.turnId), String(activeTurnId)); - assert.equal(assistantStarted.itemId, "assistant:mock-session-1:segment:0"); + assert.match( + String(assistantStarted.itemId), + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:0$/, + ); } const delta = runtimeEvents.find((event) => event.type === "content.delta"); assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(String(delta.turnId), String(activeTurnId)); - assert.equal(delta.itemId, "assistant:mock-session-1:segment:0"); + assert.match( + String(delta.itemId), + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:0$/, + ); assert.equal(delta.payload.delta, " live continuation"); } @@ -1186,7 +1192,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (contentDelta?.type === "content.delta") { assert.equal(String(contentDelta.turnId), String(turn.turnId)); assert.equal(contentDelta.payload.delta, "hello from mock"); - assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0"); + assert.match( + String(contentDelta.itemId), + /^assistant:mock-session-1:runtime:[^:]+:segment:0$/, + ); } }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 0f408a0c08fcd..7f1e8864003fb 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -769,6 +769,7 @@ export function makeCursorAdapter( : {}), ...acpNativeLoggers, }).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78f62ac212306..e969a7beab418 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -28,7 +29,7 @@ const runNode = ( effect: Effect.Effect< A, E, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); @@ -294,7 +295,7 @@ const parameterizedClaudeModelOptionConfigOptions = [ const baseCursorSettings: CursorSettings = { enabled: true, - binaryPath: "agent", + binaryPath: "cursor-agent", apiEndpoint: "", customModels: [], }; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index cd9b93a473478..063f2c1bf2baa 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -11,6 +11,7 @@ import type { import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -992,7 +993,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = getCursorFallbackModels(cursorSettings); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 8811fc1cadc3e..33a20b541cf49 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -600,6 +600,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : {}), ...acpNativeLoggers, }).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index cf5d5ad9c8d87..33f61ad97f6d9 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -167,7 +168,11 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dbfa7faffeabc..73390450efab3 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -76,7 +76,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => const makeCursorConfig = (overrides: Partial): CursorSettings => ({ enabled: false, - binaryPath: "agent", + binaryPath: "cursor-agent", apiEndpoint: "", customModels: [], ...overrides, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab11454956f..5456a90bdf518 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1396,7 +1396,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { + if (command === "cursor-agent") { cursorSpawned = true; } const joined = args.join(" "); @@ -1719,8 +1719,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("runs Claude status probes with the configured Claude HOME", () => { - const claudeHome = "/tmp/t3code-claude-home"; + it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { + const claudeConfigDir = "/tmp/t3code-claude-home"; const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1737,14 +1737,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const status = yield* checkClaudeProviderStatus( { ...defaultClaudeSettings, - homePath: claudeHome, + homePath: claudeConfigDir, }, claudeCapabilities(), ); assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.HOME), - [claudeHome], + recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), + [claudeConfigDir], ); }).pipe(Effect.provide(recorded.layer)); }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index aecb197aabda5..3390fa80b160c 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -116,6 +116,43 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps assistant item IDs unique when a provider session restarts", () => { + const collectFirstAssistantItemId = Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.sessionId).toBe("mock-session-1"); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + const assistantStart = events.find((event) => event._tag === "AssistantItemStarted"); + expect(assistantStart?._tag).toBe("AssistantItemStarted"); + return assistantStart?._tag === "AssistantItemStarted" ? assistantStart.itemId : ""; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + ); + + return Effect.gen(function* () { + const beforeRestart = yield* collectFirstAssistantItemId; + const afterRestart = yield* collectFirstAssistantItemId; + + expect(afterRestart).not.toBe(beforeRestart); + }).pipe(Effect.provide(NodeServices.layer)); + }); + it.effect("drops session updates emitted for a child ACP session", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -554,11 +591,15 @@ describe("AcpSessionRuntime", () => { expect(assistantStarted?._tag).toBe("AssistantItemStarted"); if (assistantStarted?._tag === "AssistantItemStarted") { - expect(assistantStarted.itemId).toBe("assistant:mock-session-1:segment:0"); + expect(assistantStarted.itemId).toMatch( + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:0$/, + ); } expect(contentDelta?._tag).toBe("ContentDelta"); if (contentDelta?._tag === "ContentDelta") { - expect(contentDelta.itemId).toBe("assistant:mock-session-1:segment:0"); + expect(contentDelta.itemId).toMatch( + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:0$/, + ); expect(contentDelta.text).toBe("hello from mock"); } }).pipe( @@ -633,11 +674,15 @@ describe("AcpSessionRuntime", () => { expect(assistantStarted?._tag).toBe("AssistantItemStarted"); if (assistantStarted?._tag === "AssistantItemStarted") { - expect(assistantStarted.itemId).toBe("assistant:mock-session-1:segment:3"); + expect(assistantStarted.itemId).toMatch( + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:3$/, + ); } expect(contentDelta?._tag).toBe("ContentDelta"); if (contentDelta?._tag === "ContentDelta") { - expect(contentDelta.itemId).toBe("assistant:mock-session-1:segment:3"); + expect(contentDelta.itemId).toMatch( + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:3$/, + ); } }).pipe( Effect.provide( @@ -672,12 +717,16 @@ describe("AcpSessionRuntime", () => { const assistantStarted = notes[0]; expect(assistantStarted?._tag).toBe("AssistantItemStarted"); if (assistantStarted?._tag === "AssistantItemStarted") { - expect(assistantStarted.itemId).toBe("assistant:mock-session-1:segment:0"); + expect(assistantStarted.itemId).toMatch( + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:0$/, + ); } const contentDelta = notes[1]; expect(contentDelta?._tag).toBe("ContentDelta"); if (contentDelta?._tag === "ContentDelta") { - expect(contentDelta.itemId).toBe("assistant:mock-session-1:segment:0"); + expect(contentDelta.itemId).toMatch( + /^assistant:mock-session-1:runtime:[0-9a-f-]+:segment:0$/, + ); expect(contentDelta.text).toBe(" live continuation"); } }).pipe( diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 704b961f1c06b..4a37245a43d15 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -286,14 +287,24 @@ export const make = ( ): Effect.Effect< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to generate an ACP assistant item runtime identifier.", + cause, + }), + ), + ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); @@ -406,6 +417,7 @@ export const make = ( if (action._tag === "ObserveReplay") { yield* observeSessionLoadAssistantSegments({ assistantSegmentRef, + assistantItemRuntimeId, params: notification, }); return; @@ -433,6 +445,7 @@ export const make = ( modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params: notification, }); }), @@ -702,6 +715,7 @@ export const make = ( modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params: notification, }); } @@ -884,7 +898,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -916,12 +930,14 @@ const handleSessionUpdate = ({ modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; + readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -969,6 +985,7 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, sessionId: params.sessionId, + assistantItemRuntimeId, }); yield* Queue.offer(queue, { ...event, @@ -1006,23 +1023,25 @@ function shouldEmitToolCallUpdate( return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } -const assistantItemId = (sessionId: string, segmentIndex: number) => - `assistant:${sessionId}:segment:${segmentIndex}`; +const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => + `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; const ensureActiveAssistantSegmentState = ({ assistantSegmentRef, sessionId, + assistantItemRuntimeId, replayOnly = false, }: { readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; + readonly assistantItemRuntimeId: string; readonly replayOnly?: boolean; }) => Ref.update(assistantSegmentRef, (current) => { if (current.activeItemId) { return current; } - const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); return { nextSegmentIndex: current.nextSegmentIndex + 1, activeItemId: itemId, @@ -1045,9 +1064,11 @@ const closeActiveAssistantSegmentState = ({ const observeSessionLoadAssistantSegments = ({ assistantSegmentRef, + assistantItemRuntimeId, params, }: { readonly assistantSegmentRef: Ref.Ref; + readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -1071,6 +1092,7 @@ const observeSessionLoadAssistantSegments = ({ yield* ensureActiveAssistantSegmentState({ assistantSegmentRef, sessionId: params.sessionId, + assistantItemRuntimeId, replayOnly: true, }); } @@ -1080,10 +1102,12 @@ const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, sessionId, + assistantItemRuntimeId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; + readonly assistantItemRuntimeId: string; }) => Ref.modify( assistantSegmentRef, @@ -1102,7 +1126,7 @@ const ensureActiveAssistantSegment = ({ current.activeItemReplayOnly ? { ...current, activeItemReplayOnly: false } : current, ] as const; } - const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); return [ { itemId, diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index eebe5ddd92ecd..6be4faf967018 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -1,5 +1,5 @@ /** - * Optional integration check against a real `agent acp` install. + * Optional integration check against a real `cursor-agent acp` install. * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe */ import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -12,7 +12,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { - it.effect("initialize and authenticate against real agent acp", () => + it.effect("initialize and authenticate against real cursor-agent acp", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); @@ -21,7 +21,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", Effect.provide( AcpSessionRuntime.layer({ spawn: { - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: process.cwd(), }, @@ -77,7 +77,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", AcpSessionRuntime.layer({ authMethodId: "cursor_login", spawn: { - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: process.cwd(), }, @@ -133,7 +133,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", AcpSessionRuntime.layer({ authMethodId: "cursor_login", spawn: { - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: process.cwd(), }, diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index 78bc16c9f3bb7..a095fdd679bce 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -53,7 +53,7 @@ const parameterizedGpt54ConfigOptions: ReadonlyArray { it("builds the default Cursor ACP command", () => { expect(buildCursorAcpSpawnInput(undefined, "/tmp/project")).toEqual({ - command: "agent", + command: "cursor-agent", args: ["acp"], cwd: "/tmp/project", }); diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 169d7c6206d34..30203ad77b1e7 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,5 @@ import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -35,7 +36,7 @@ export function buildCursorAcpSpawnInput( environment?: NodeJS.ProcessEnv, ): AcpSessionRuntime.AcpSpawnInput { return { - command: cursorSettings?.binaryPath || "agent", + command: cursorSettings?.binaryPath || "cursor-agent", args: [ ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", @@ -50,7 +51,7 @@ export const makeCursorAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Scope.Scope + Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const acpContext = yield* Layer.build( diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3e6d63a53935f..c928b3ed80e07 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,5 @@ import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -55,7 +56,7 @@ export const makeGrokAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Scope.Scope + Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const acpContext = yield* Layer.build( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce4267..641c9b52e56c7 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -48,7 +48,7 @@ function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabili return makeProviderMaintenanceCapabilities({ provider, packageName: null, - updateExecutable: "agent", + updateExecutable: "cursor-agent", updateArgs: ["update"], updateLockKey: "cursor-agent", }); @@ -226,7 +226,7 @@ describe("providerMaintenanceRunner", () => { const result = yield* updater.updateProvider(CURSOR_DRIVER); assert.deepStrictEqual(calls, [ { - command: "agent", + command: "cursor-agent", args: ["update"], }, ]); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 4e60864be0354..74849e9278b83 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1396,6 +1396,63 @@ it.layer( }), ); + it.effect("strips AppImage runtime env from terminal sessions", () => + Effect.gen(function* () { + const appDir = "/tmp/.mount_T3Codeabc123"; + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + APPIMAGE: "/home/user/T3-Code.AppImage", + APPDIR: appDir, + ARGV0: "/home/user/T3-Code.AppImage", + OWD: "/home/user/project", + PATH: `${appDir}/usr/bin:${appDir}:/usr/local/bin:/usr/bin:/bin`, + LD_LIBRARY_PATH: `${appDir}/usr/lib:/home/user/.local/lib`, + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + // AppImage runtime markers must never reach the PTY — tools inside the + // terminal otherwise resolve against the AppImage mount (e.g. PHP_BINARY + // reporting the AppImage path instead of the real binary). + expect(spawnInput.env.APPIMAGE).toBeUndefined(); + expect(spawnInput.env.APPDIR).toBeUndefined(); + expect(spawnInput.env.ARGV0).toBeUndefined(); + expect(spawnInput.env.OWD).toBeUndefined(); + // PATH/LD_LIBRARY_PATH keep the user's real entries but drop the AppImage + // mount segments that the runtime prepended. + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + // Unrelated host vars still pass through untouched. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); + }), + ); + + it.effect("leaves the environment untouched when not launched from an AppImage", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/local/bin:/usr/bin:/bin", + LD_LIBRARY_PATH: "/home/user/.local/lib", + // Without APPIMAGE/APPDIR set, OWD is an ordinary variable and must + // not be stripped — only an AppImage launch gives it special meaning. + OWD: "/home/user/keep-this", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + expect(spawnInput.env.OWD).toBe("/home/user/keep-this"); + }), + ); + it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6347fdfc64d65..caa5106bb9fd3 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1065,6 +1065,53 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } +// Marker variables the AppImage runtime injects into the process it launches. +// They describe the AppImage itself, not the user's session, so terminals must +// not inherit them. +const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const; +// PATH-style variables the AppImage runtime prepends with its temporary mount +// (e.g. /tmp/.mount_T3-XXXX/usr/bin). Only the mount segments are dropped; the +// user's real entries are preserved. +const APPIMAGE_PATH_LIKE_ENV_KEYS = ["PATH", "LD_LIBRARY_PATH"] as const; + +function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { + return segment === appDir || segment.startsWith(`${appDir}/`); +} + +// On Linux AppImage builds the runtime mounts the app under a temporary dir and +// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. +// The integrated terminal inherits the server process environment, so without +// this scrub those leak into the PTY and tools resolve against the AppImage +// mount instead of the user's real environment (e.g. `php` reporting +// PHP_BINARY as the AppImage path). See issue #1699. The scrub is gated on an +// actual AppImage launch so non-AppImage environments are left untouched. +function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; + + const scrubbed: NodeJS.ProcessEnv = { ...env }; + for (const key of APPIMAGE_RUNTIME_ENV_KEYS) { + delete scrubbed[key]; + } + + const appDir = env.APPDIR?.replace(/\/+$/, ""); + if (appDir) { + for (const key of APPIMAGE_PATH_LIKE_ENV_KEYS) { + const value = scrubbed[key]; + if (value === undefined) continue; + const kept = value + .split(":") + .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir)); + if (kept.length > 0) { + scrubbed[key] = kept.join(":"); + } else { + delete scrubbed[key]; + } + } + } + + return scrubbed; +} + function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record | null, @@ -1080,7 +1127,7 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } - return spawnEnv; + return stripAppImageRuntimeEnv(spawnEnv); } function normalizedRuntimeEnv( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index c8fe4ead3be72..0f3905a0cb114 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -51,8 +51,8 @@ function makeFakeClaudeBinary(dir: string) { " exit 4", " }", "fi", - 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', - ' printf "%s\\n" "HOME was $HOME" >&2', + 'if [ -n "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ] && [ "$CLAUDE_CONFIG_DIR" != "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ]; then', + ' printf "%s\\n" "CLAUDE_CONFIG_DIR was $CLAUDE_CONFIG_DIR" >&2', " exit 5", "fi", 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', @@ -76,7 +76,7 @@ function withFakeClaudeEnv( argsMustContain?: string; argsMustNotContain?: string; stdinMustContain?: string; - homeMustBe?: string; + configDirMustBe?: string; claudeConfig?: Partial; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, @@ -92,7 +92,7 @@ function withFakeClaudeEnv( const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; yield* Effect.acquireRelease( Effect.sync(() => { @@ -129,10 +129,10 @@ function withFakeClaudeEnv( delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; } - if (input.homeMustBe !== undefined) { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; + if (input.configDirMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe; } else { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } }), () => @@ -175,10 +175,10 @@ function withFakeClaudeEnv( process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; } - if (previousHomeMustBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + if (previousConfigDirMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } else { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; + process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; } }), ); @@ -286,10 +286,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); - it.effect("runs Claude text generation with the configured Claude HOME", () => + it.effect("runs Claude text generation with the configured CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; - const claudeHome = path.join(process.cwd(), ".claude-work-test"); + const claudeConfigDir = path.join(process.cwd(), ".claude-work-test"); return yield* withFakeClaudeEnv( { // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -298,8 +298,8 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { title: "Use Claude home", }, }), - homeMustBe: claudeHome, - claudeConfig: { homePath: claudeHome }, + configDirMustBe: claudeConfigDir, + claudeConfig: { homePath: claudeConfigDir }, }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 3e1f4eb8bbcf0..6d1e5d02db34c 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -1,3 +1,4 @@ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -38,6 +39,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, ) { + const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const resolvedEnvironment = environment ?? process.env; @@ -66,7 +68,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb5821630567..fd367acdf4c4c 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,3 +1,4 @@ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -37,6 +38,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) { + const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runGrokJson = ({ @@ -65,7 +67,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f1daf1143fd95..5ce5a2ad2fb8b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6,6 +6,9 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -20,6 +23,21 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); +const makeNonRepositoryHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make("fatal: not a git repository")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -77,7 +95,65 @@ const initRepoWithCommit = ( return { initialBranch }; }); +it.effect("uses stable diagnostics for every parsed non-repository command", () => { + const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (!ChildProcess.isStandardCommand(command)) { + return assert.fail("expected a standard Git command"); + } + commands.push({ + args: command.args, + ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), + }); + return makeNonRepositoryHandle(); + }), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = "/repo"; + + yield* driver.statusDetailsLocal(cwd); + yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + yield* driver.listRefs({ cwd }); + + assert.deepStrictEqual(commands, [ + { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, + { + args: ["for-each-ref", "--format=%(refname)%09%(committerdate:unix)", "refs/heads"], + lcAll: "C", + }, + ]); + }).pipe(Effect.provide(layer)); +}); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("process environment", () => { + it.effect("preserves the caller locale for general Git subprocesses", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + + const locale = yield* git( + cwd, + ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], + { LC_ALL: "zh_CN.UTF-8" }, + ); + + assert.equal(locale, "zh_CN.UTF-8"); + }), + ); + }); + describe("structured errors", () => { it.effect("preserves structured spawn context and the platform cause", () => Effect.gen(function* () { @@ -732,6 +808,24 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(status, "a.txt"); }), ); + + it.effect("treats selected file paths literally", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); + yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); + + yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? selected1.txt"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5cbb268cbc42f..373b35b7fb900 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -871,6 +871,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const executeGitWithStableDiagnostics = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, { + ...options, + env: { + ...options.env, + LC_ALL: "C", + }, + }); + const runGit = ( operation: string, cwd: string, @@ -1225,7 +1239,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { - const branchResult = yield* executeGit( + const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, ["rev-parse", "--abbrev-ref", "HEAD"], @@ -1309,7 +1323,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { - const statusResult = yield* executeGit( + const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], @@ -1529,6 +1543,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "--literal-pathspecs", "add", "-A", "--", @@ -1988,7 +2003,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( function* (input) { const refListFormat = "%(refname)%09%(committerdate:unix)"; - const localRefListResult = yield* executeGit( + const localRefListResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.localForEachRef", input.cwd, ["for-each-ref", `--format=${refListFormat}`, "refs/heads"], @@ -2028,7 +2043,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `GitVcsDriver.listRefs: local ref lookup returned code ${localRefListResult.exitCode} for ${input.cwd}: ${stderr}. Falling back to git branch.`, ); - const fallbackLocalBranchResult = yield* executeGit( + const fallbackLocalBranchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.localBranchFallback", input.cwd, ["branch", "--no-color", "--no-column"], diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 455cc9199f644..aefa9ab9f4186 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -55,6 +55,9 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; @@ -92,7 +95,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const unsubscribe = onMenuAction((action) => { if (action === "open-settings") { - void navigate({ to: "/settings" }); + const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current); + if (!isSettingsRoute) { + void navigate({ to: "/settings" }); + } } }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index d826eca0063ac..bd02a91b248e1 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + {option.description} ) : null}
{isSelected ? ( diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 623801d68e89c..7a6b513985599 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts new file mode 100644 index 0000000000000..4ac086157a2f8 --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getPreviewPanelMaxWidth } from "./PreviewPanelShell"; + +describe("getPreviewPanelMaxWidth", () => { + it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => { + expect(getPreviewPanelMaxWidth(6_000)).toBe(4_200); + }); + + it("rounds fractional CSS pixels down", () => { + expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400); + }); +}); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 6de828c1e63e2..a12c23e386bb7 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -10,12 +10,14 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; const PREVIEW_PANEL_MIN_WIDTH = 360; -/** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */ -const PREVIEW_PANEL_MAX_WIDTH_PX = 1400; -/** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */ +/** Fraction of the viewport allowed, preserving the remaining space for chat. */ const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; const PREVIEW_PANEL_DEFAULT_WIDTH = 540; +export function getPreviewPanelMaxWidth(viewportWidth: number): number { + return Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); +} + /** * Shell for the preview panel. In inline mode the panel is user-resizable * via a drag handle on the left edge; width persists per browser. In @@ -82,5 +84,5 @@ function useViewportClampedMaxWidth(): number { if (frame !== 0) window.cancelAnimationFrame(frame); }; }, []); - return Math.min(PREVIEW_PANEL_MAX_WIDTH_PX, Math.floor(vw * PREVIEW_PANEL_MAX_WIDTH_FRACTION)); + return getPreviewPanelMaxWidth(vw); } diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index c6ada2cb49147..dac80f070b482 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -17,7 +17,7 @@ interface Props { export function PreviewUnreachable({ url, code, description, onReload }: Props) { const [showDetails, setShowDetails] = useState(false); const host = safeHost(url) ?? url; - const friendly = describePreviewError(code, description); + const friendly = describePreviewError(description); const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; return ( diff --git a/apps/web/src/components/preview/errorCodeMessages.ts b/apps/web/src/components/preview/errorCodeMessages.ts index 78ee928800b5d..76ce7d9392302 100644 --- a/apps/web/src/components/preview/errorCodeMessages.ts +++ b/apps/web/src/components/preview/errorCodeMessages.ts @@ -2,11 +2,11 @@ import { PREVIEW_ERROR_CODE_MESSAGES } from "./previewConstants"; /** * Resolve a friendly description for a Chromium / network error. Falls back - * to the description string passed in when the code isn't in our table. + * to the description string passed in when it isn't in our table. */ -export function describePreviewError(code: number, description: string): string { +export function describePreviewError(description: string): string { const friendly = PREVIEW_ERROR_CODE_MESSAGES[description]; if (friendly) return friendly; if (description.length > 0) return description; - return `Network error (${code})`; + return "Network error"; } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 92d8d3f682727..b1a54feb71888 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -23,7 +23,7 @@ import * as Option from "effect/Option"; import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; -import { formatRelativeTime } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; import { primaryServerAvailableEditorsAtom, @@ -65,8 +65,7 @@ function formatBytes(value: number): string { function formatRelative(value: DateTime.Utc | null): string { if (!value) return "No trace records"; - const relative = formatRelativeTime(DateTime.formatIso(value)); - return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; + return formatRelativeTimeLabel(DateTime.formatIso(value)); } function formatRelativeNoWrap(value: DateTime.Utc | null): string { @@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({ function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { useRelativeTimeTick(); - const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null; + const relative = getRelativeTimeState(checkedAt ? DateTime.formatIso(checkedAt) : null); - if (!relative) { + if (relative.status === "missing") { return Checking; } + if (relative.status === "invalid") { + return Checked unavailable; + } + return ( {relative.suffix ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 345bfb663fc07..037da7e326de8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -57,7 +57,7 @@ import { import { usePrimaryEnvironment } from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -133,12 +133,16 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { useRelativeTimeTick(); - const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - if (!lastCheckedRelative) { + if (lastCheckedRelative.status === "missing") { return null; } + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + return ( {lastCheckedRelative.suffix ? ( @@ -404,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), + ...(settings.enableProviderUpdateChecks !== + DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks + ? ["Provider update checks"] + : []), ...(Duration.toMillis(settings.automaticGitFetchInterval) !== Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) ? ["Automatic Git fetch interval"] @@ -438,6 +446,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableGenerativeUi, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, + settings.enableProviderUpdateChecks, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, @@ -464,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index ac7a03eeb2685..e0020d7d5588a 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -15,8 +15,48 @@ import { shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +function SidebarUpdateReleaseNotesTooltip({ + state, + tooltip, +}: { + readonly state: NonNullable>; + readonly tooltip: string; +}) { + if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + return <>{tooltip}; + } + + return ( +
+
+
{tooltip}
+
+
+ {state.releaseNotes.map((releaseNote, index) => ( +
+ {index > 0 && } +
+

+ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} +

+
    + {releaseNote.items.map((item, itemIndex) => ( +
  • + {item} +
  • + ))} +
+
+
+ ))} +
+
+ ); +} + export function SidebarUpdatePill() { const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); @@ -156,7 +196,21 @@ export function SidebarUpdatePill() { } /> - {tooltip} + 0 + ? "max-w-none text-balance" + : undefined + } + side="top" + > + {state ? ( + + ) : ( + tooltip + )} + {action === "download" && ( diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 19b7f43f8c8d6..99ac6bba7163b 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -8,9 +8,24 @@ import { isCollapsedCursorAdjacentToInlineToken, parseStandaloneComposerSlashCommand, replaceTextRange, + shouldSubmitComposerOnEnter, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +describe("shouldSubmitComposerOnEnter", () => { + it("submits plain Enter on desktop", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: false })).toBe(true); + }); + + it("inserts a newline for plain Enter on mobile", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: true, shiftKey: false })).toBe(false); + }); + + it("inserts a newline for Shift+Enter", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: true })).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 46b2c964ae877..2d1d3aed3b1e6 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -11,6 +11,13 @@ export interface ComposerTrigger { rangeEnd: number; } +export function shouldSubmitComposerOnEnter(input: { + isMobileViewport: boolean; + shiftKey: boolean; +}): boolean { + return !input.isMobileViewport && !input.shiftKey; +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 56b4596b9f027..29596e72a9ffe 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -196,6 +196,23 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("ignores a click from the gesture that opened the menu", async () => { + let enablePointerSelection: ((time: number) => void) | undefined; + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + enablePointerSelection = callback; + return 0; + }); + + const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]); + const renameButton = findButton("Rename"); + + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + enablePointerSelection?.(0); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index a301e0c92c8b0..9b3bb94dbcefe 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -272,7 +272,9 @@ export function showContextMenuFallback( button.addEventListener("mouseenter", () => { closeMenusFromLevel(level + 1); }); - button.addEventListener("click", () => cleanup(item.id)); + button.addEventListener("click", () => { + if (canDismissFromPointer) cleanup(item.id); + }); } } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5a0d1f394c0d3..4aa7a64fcfe25 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -6,6 +6,7 @@ :root { --app-scrollbar-width: 6px; + --desktop-window-right-resize-inset: 0px; --workspace-topbar-height: 52px; --workspace-controls-top: 0px; --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); @@ -375,6 +376,11 @@ body { overflow-y: hidden; overscroll-behavior-y: none; padding-top: max(env(safe-area-inset-top), 0px); + padding-right: var(--desktop-window-right-resize-inset); +} + +.electron-windows { + --desktop-window-right-resize-inset: 6px; } /* App-chrome grain. Baked into each surface's own background (behind diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 8f9ae786b0e0f..42f9f13c7cda8 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -1,4 +1,8 @@ +import { isWindowsPlatform } from "./utils"; + const WCO_CLASS_NAME = "wco"; +const ELECTRON_CLASS_NAME = "electron"; +const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows"; interface WindowControlsOverlayLike { readonly visible: boolean; @@ -38,3 +42,25 @@ export function syncDocumentWindowControlsOverlayClass(): () => void { overlay.removeEventListener("geometrychange", update); }; } + +export function getElectronPlatformClassNames( + platform: string, +): + | readonly [typeof ELECTRON_CLASS_NAME] + | readonly [typeof ELECTRON_CLASS_NAME, typeof ELECTRON_WINDOWS_CLASS_NAME] { + return isWindowsPlatform(platform) + ? [ELECTRON_CLASS_NAME, ELECTRON_WINDOWS_CLASS_NAME] + : [ELECTRON_CLASS_NAME]; +} + +export function syncDocumentElectronPlatformClasses(platform: string): () => void { + if (typeof document === "undefined") { + return () => {}; + } + + const classNames = getElectronPlatformClassNames(platform); + document.documentElement.classList.add(...classNames); + return () => { + document.documentElement.classList.remove(...classNames); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 453649bfdc59e..c03cc65f6541c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -15,7 +15,10 @@ import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; -import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +import { + syncDocumentElectronPlatformClasses, + syncDocumentWindowControlsOverlayClass, +} from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. @@ -24,6 +27,7 @@ const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); if (isElectron) { + syncDocumentElectronPlatformClasses(navigator.platform); syncDocumentWindowControlsOverlayClass(); } diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index a2bcbd19a33f5..0c05b2d453958 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c68d5b7ee4655..c2fe4b62714fc 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { formatElapsedDurationLabel, formatExpiresInLabel, + formatRelativeTime, + formatRelativeTimeLabel, + formatRelativeTimeUntil, formatRelativeTimeUntilLabel, + formatShortTimestamp, + formatTimestamp, + getRelativeTimeState, getTimestampFormatOptions, } from "./timestampFormat"; @@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => { }); }); +describe("invalid timestamp inputs", () => { + it("returns an empty timestamp instead of throwing", () => { + expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow(); + expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); + }); + + it("returns an empty short timestamp instead of throwing", () => { + expect(() => formatShortTimestamp("not-a-date", "12-hour")).not.toThrow(); + expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); + }); + + it("returns an empty relative time label instead of a NaN label", () => { + expect(formatRelativeTime("not-a-date")).toBeNull(); + expect(formatRelativeTimeLabel("not-a-date")).toBe(""); + }); + + it("distinguishes missing and invalid relative time state", () => { + expect(getRelativeTimeState(null)).toEqual({ status: "missing" }); + expect(getRelativeTimeState("not-a-date")).toEqual({ status: "invalid" }); + }); + + it("returns an empty elapsed duration instead of a NaN label", () => { + expect(formatElapsedDurationLabel("not-a-date")).toBe(""); + }); + + it("returns an empty relative time until label instead of a NaN label", () => { + expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); + expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); + }); + + it("returns an empty expires-in label instead of a NaN label", () => { + expect(formatExpiresInLabel("not-a-date")).toBe(""); + }); +}); + +describe("getRelativeTimeState", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns relative parts for valid timestamps", () => { + expect(getRelativeTimeState("2026-04-07T11:45:00.000Z")).toEqual({ + status: "relative", + value: "15m", + suffix: "ago", + }); + }); +}); + describe("formatElapsedDurationLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 28facf877e548..31cc41798ebe8 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -40,8 +40,15 @@ function getTimestampFormatter( return formatter; } +function parseTimestampDate(isoDate: string): Date | null { + const date = new Date(isoDate); + return Number.isNaN(date.getTime()) ? null : date; +} + export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate)); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return getTimestampFormatter(timestampFormat, true).format(date); } const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); @@ -69,8 +76,8 @@ export function formatChatTimestampTooltip( isoDate: string, timestampFormat: TimestampFormat, ): string { - const date = new Date(isoDate); - if (Number.isNaN(date.getTime())) return ""; + const date = parseTimestampDate(isoDate); + if (!date) return ""; const time = formatShortTimestamp(isoDate, timestampFormat); const day = date.getDate(); const month = monthNameFormatter.format(date); @@ -79,7 +86,9 @@ export function formatChatTimestampTooltip( } export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return getTimestampFormatter(timestampFormat, false).format(date); } /** @@ -87,8 +96,16 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` * so callers can style the numeric portion independently. */ -export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { - const diffMs = Date.now() - new Date(isoDate).getTime(); +type RelativeTimeParts = { value: string; suffix: string | null }; +export type RelativeTimeState = + | { status: "missing" } + | { status: "invalid" } + | { status: "relative"; value: string; suffix: string | null }; + +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { + const date = parseTimestampDate(isoDate); + if (!date) return null; + const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; @@ -102,15 +119,25 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st export function formatRelativeTimeLabel(isoDate: string) { const relative = formatRelativeTime(isoDate); + if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } +export function getRelativeTimeState(isoDate: string | null): RelativeTimeState { + if (!isoDate) return { status: "missing" }; + const relative = formatRelativeTime(isoDate); + if (!relative) return { status: "invalid" }; + return { status: "relative", ...relative }; +} + /** * Relative elapsed duration since an ISO instant, without an "ago" suffix. * Useful for labels like "Connected for 3m". */ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { - const diffMs = nowMs - new Date(isoDate).getTime(); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const diffMs = nowMs - date.getTime(); if (diffMs <= 0) return "just now"; const seconds = Math.floor(diffMs / 1000); @@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date /** * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. */ -export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } { - const diffMs = new Date(isoDate).getTime() - Date.now(); +export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { + const date = parseTimestampDate(isoDate); + if (!date) return null; + const diffMs = date.getTime() - Date.now(); if (diffMs <= 0) return { value: "Expired", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 5) return { value: "Soon", suffix: null }; @@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi export function formatRelativeTimeUntilLabel(isoDate: string): string { const relative = formatRelativeTimeUntil(isoDate); + if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } @@ -154,7 +184,9 @@ export function formatRelativeTimeUntilLabel(isoDate: string): string { * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. */ export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { - const diffMs = new Date(isoDate).getTime() - nowMs; + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const diffMs = date.getTime() - nowMs; if (diffMs <= 0) return "Expired"; const totalSeconds = Math.floor(diffMs / 1000); diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000000000..8a7e3c5f20a87 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,50 @@ +# Brand icons + +The three Icon Composer projects are the source of truth for full application icons: + +- `dev/app-icon.icon` +- `nightly/app-icon.icon` +- `prod/app-icon.icon` + +Each project uses `text.svg` for the T3 mark and `background.svg` when the background is a vector layer. Additional layers use semantic names that describe their role and placement. + +Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. Run `vp run icons:check` to verify that those generated assets match their sources without changing files. + +Exporting requires Icon Composer 2 or newer on macOS. The script selects the newest compatible exporter from Xcode or a standalone Icon Composer installation and pins design generation 26. Set `ICON_COMPOSER_TOOL` to the full path of `Icon Composer.app/Contents/Executables/ictool` to override automatic discovery. + +## macOS exports + +Icon Composer's command-line exporter does not expose the `macOS pre-Tahoe` preset. A plain command-line `macOS` export is full bleed and is not suitable for the desktop app, so the export script intentionally leaves the tracked macOS PNGs unchanged and prints a reminder after every run. + +After changing an Icon Composer project, open it in Icon Composer and export the macOS PNG with exactly these settings: + +- Platform: `macOS pre-Tahoe` +- Appearance: `Default` +- Size: `1024pt` +- Scale: `1×` + +Save the three exports to: + +- `dev/app-icon.icon` -> `dev/blueprint-macos-1024.png` +- `nightly/app-icon.icon` -> `nightly/nightly-macos-1024.png` +- `prod/app-icon.icon` -> `prod/black-macos-1024.png` + +The result must be a 1024×1024 PNG with the classic macOS safe area: the opaque icon body is 824×824, inset 100 pixels on every side, with only the native Icon Composer shadow extending into the surrounding transparent canvas. + +To have Codex perform the native exports, paste this prompt into a task opened at the repository root: + +```text +Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository. + +For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination: + +- assets/dev/app-icon.icon -> assets/dev/blueprint-macos-1024.png +- assets/nightly/app-icon.icon -> assets/nightly/nightly-macos-1024.png +- assets/prod/app-icon.icon -> assets/prod/black-macos-1024.png + +Do not resize, composite, or otherwise post-process the exported PNGs. + +Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it. +``` + +Do not edit the generated PNG or ICO files directly. diff --git a/assets/dev/app-icon.icon/Assets/annotations.svg b/assets/dev/app-icon.icon/Assets/annotations.svg new file mode 100644 index 0000000000000..dffde33559ea7 --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/annotations.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/dev/app-icon.icon/Assets/background.svg b/assets/dev/app-icon.icon/Assets/background.svg new file mode 100644 index 0000000000000..db5c319d607d7 --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/background.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/dev/app-icon.icon/Assets/text.svg b/assets/dev/app-icon.icon/Assets/text.svg new file mode 100644 index 0000000000000..0b1825f2ef471 --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/icon-composer-dev.icon/icon.json b/assets/dev/app-icon.icon/icon.json similarity index 53% rename from apps/mobile/assets/icon-composer-dev.icon/icon.json rename to assets/dev/app-icon.icon/icon.json index fd3fb6819a919..ff7d43d781120 100644 --- a/apps/mobile/assets/icon-composer-dev.icon/icon.json +++ b/assets/dev/app-icon.icon/icon.json @@ -6,35 +6,40 @@ { "layers": [ { - "hidden": false, - "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png", - "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr", + "glass": true, + "image-name": "text.svg", + "name": "Text", "position": { - "scale": 1.1, + "scale": 8.5, "translation-in-points": [0, 0] } }, { - "image-name": "T3.svg", - "name": "T3", + "glass": false, + "image-name": "annotations.svg", + "name": "Annotations", "position": { - "scale": 10, + "scale": 8.5, "translation-in-points": [0, 0] } }, { - "hidden": true, - "image-name": "Texturelabs_Paper_381XL.jpg", - "name": "Texturelabs_Paper_381XL" + "glass": true, + "image-name": "background.svg", + "name": "Background", + "position": { + "scale": 8.1, + "translation-in-points": [0, 0] + } } ], "shadow": { "kind": "neutral", - "opacity": 0.5 + "opacity": 0.6 }, "translucency": { "enabled": true, - "value": 0.5 + "value": 0.2 } } ], diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc230..0000000000000 --- a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e83..0000000000000 Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png deleted file mode 100644 index fa7700d01c73f..0000000000000 Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png and /dev/null differ diff --git a/assets/dev/blueprint-icon-composer.icon/icon.json b/assets/dev/blueprint-icon-composer.icon/icon.json deleted file mode 100644 index 1c15123dab4f9..0000000000000 --- a/assets/dev/blueprint-icon-composer.icon/icon.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "fill": { - "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" - }, - "groups": [ - { - "layers": [ - { - "blend-mode": "normal", - "fill": "automatic", - "glass": true, - "hidden": false, - "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png", - "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871", - "position": { - "scale": 1.05, - "translation-in-points": [0, 0] - } - }, - { - "image-name": "T3.svg", - "name": "T3", - "position": { - "scale": 10, - "translation-in-points": [0, 0] - } - }, - { - "hidden": false, - "image-name": "Texturelabs_Paper_381XL.jpg", - "name": "Texturelabs_Paper_381XL" - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.5 - } - } - ], - "supported-platforms": { - "squares": ["iOS", "macOS"] - } -} diff --git a/assets/dev/blueprint-ios-1024.png b/assets/dev/blueprint-ios-1024.png index 51b764ac72ae3..a53c8d19aa83a 100644 Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ diff --git a/assets/dev/blueprint-macos-1024.png b/assets/dev/blueprint-macos-1024.png index 7c165fdb851b2..68a567058be10 100644 Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ diff --git a/assets/dev/blueprint-universal-1024.png b/assets/dev/blueprint-universal-1024.png index 51b764ac72ae3..a53c8d19aa83a 100644 Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ diff --git a/assets/dev/blueprint-web-apple-touch-180.png b/assets/dev/blueprint-web-apple-touch-180.png index 4a135d22fdbcc..3eed25ea6b785 100644 Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ diff --git a/assets/dev/blueprint-web-favicon-16x16.png b/assets/dev/blueprint-web-favicon-16x16.png index 56e0e837131ea..a3431b8c6dfef 100644 Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ diff --git a/assets/dev/blueprint-web-favicon-32x32.png b/assets/dev/blueprint-web-favicon-32x32.png index 9e9f31ea9893f..862f7629971fa 100644 Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ diff --git a/assets/dev/blueprint-web-favicon.ico b/assets/dev/blueprint-web-favicon.ico index e0ee3e1408f02..750da22602eec 100644 Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ diff --git a/assets/dev/blueprint-windows.ico b/assets/dev/blueprint-windows.ico index e0ee3e1408f02..750da22602eec 100644 Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ diff --git a/assets/nightly/app-icon.icon/Assets/background.svg b/assets/nightly/app-icon.icon/Assets/background.svg new file mode 100644 index 0000000000000..1ceca91c02367 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/background.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg new file mode 100644 index 0000000000000..d43478860f469 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg new file mode 100644 index 0000000000000..7b094441db156 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/text.svg b/assets/nightly/app-icon.icon/Assets/text.svg new file mode 100644 index 0000000000000..0b1825f2ef471 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nightly/app-icon.icon/icon.json b/assets/nightly/app-icon.icon/icon.json new file mode 100644 index 0000000000000..eb48d26b4a521 --- /dev/null +++ b/assets/nightly/app-icon.icon/icon.json @@ -0,0 +1,94 @@ +{ + "fill": { + "automatic-gradient": "display-p3:0.00000,0.00000,0.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "glass": true, + "image-name": "text.svg", + "name": "Text", + "position": { + "scale": 8.5, + "translation-in-points": [0, 0] + } + }, + { + "glass": false, + "image-name": "cloud-upper-right.svg", + "name": "Cloud Upper Right", + "opacity": 1, + "position": { + "scale": 15, + "translation-in-points": [387.9605131881942, -134.30064713259117] + } + }, + { + "glass": false, + "image-name": "cloud-lower-left.svg", + "name": "Cloud Lower Left", + "position": { + "scale": 25, + "translation-in-points": [-309.63750000000005, 268.66077693836917] + } + }, + { + "glass": true, + "hidden": false, + "image-name": "background.svg", + "name": "Background", + "position": { + "scale": 8.1, + "translation-in-points": [0, -2.171875] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/assets/nightly/blueprint-ios-1024.png b/assets/nightly/blueprint-ios-1024.png deleted file mode 100644 index b33d6f337b0b6..0000000000000 Binary files a/assets/nightly/blueprint-ios-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-macos-1024.png b/assets/nightly/blueprint-macos-1024.png deleted file mode 100644 index 8dba03e01fe1b..0000000000000 Binary files a/assets/nightly/blueprint-macos-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-universal-1024.png b/assets/nightly/blueprint-universal-1024.png deleted file mode 100644 index b33d6f337b0b6..0000000000000 Binary files a/assets/nightly/blueprint-universal-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-apple-touch-180.png b/assets/nightly/blueprint-web-apple-touch-180.png deleted file mode 100644 index e0e1b9659b83e..0000000000000 Binary files a/assets/nightly/blueprint-web-apple-touch-180.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon-16x16.png b/assets/nightly/blueprint-web-favicon-16x16.png deleted file mode 100644 index 673d84599981c..0000000000000 Binary files a/assets/nightly/blueprint-web-favicon-16x16.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon-32x32.png b/assets/nightly/blueprint-web-favicon-32x32.png deleted file mode 100644 index 25bcc95d4aaa0..0000000000000 Binary files a/assets/nightly/blueprint-web-favicon-32x32.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon.ico b/assets/nightly/blueprint-web-favicon.ico deleted file mode 100644 index 36975b9978275..0000000000000 Binary files a/assets/nightly/blueprint-web-favicon.ico and /dev/null differ diff --git a/assets/nightly/blueprint-windows.ico b/assets/nightly/blueprint-windows.ico deleted file mode 100644 index 36975b9978275..0000000000000 Binary files a/assets/nightly/blueprint-windows.ico and /dev/null differ diff --git a/assets/nightly/nightly-ios-1024.png b/assets/nightly/nightly-ios-1024.png new file mode 100644 index 0000000000000..42ce5589e9cac Binary files /dev/null and b/assets/nightly/nightly-ios-1024.png differ diff --git a/assets/nightly/nightly-macos-1024.png b/assets/nightly/nightly-macos-1024.png new file mode 100644 index 0000000000000..1beacd9f34ade Binary files /dev/null and b/assets/nightly/nightly-macos-1024.png differ diff --git a/assets/nightly/nightly-universal-1024.png b/assets/nightly/nightly-universal-1024.png new file mode 100644 index 0000000000000..42ce5589e9cac Binary files /dev/null and b/assets/nightly/nightly-universal-1024.png differ diff --git a/assets/nightly/nightly-web-apple-touch-180.png b/assets/nightly/nightly-web-apple-touch-180.png new file mode 100644 index 0000000000000..f09a169458ab4 Binary files /dev/null and b/assets/nightly/nightly-web-apple-touch-180.png differ diff --git a/assets/nightly/nightly-web-favicon-16x16.png b/assets/nightly/nightly-web-favicon-16x16.png new file mode 100644 index 0000000000000..20208f5f36951 Binary files /dev/null and b/assets/nightly/nightly-web-favicon-16x16.png differ diff --git a/assets/nightly/nightly-web-favicon-32x32.png b/assets/nightly/nightly-web-favicon-32x32.png new file mode 100644 index 0000000000000..b9008d518bce0 Binary files /dev/null and b/assets/nightly/nightly-web-favicon-32x32.png differ diff --git a/assets/nightly/nightly-web-favicon.ico b/assets/nightly/nightly-web-favicon.ico new file mode 100644 index 0000000000000..b6a0b43b93d2e Binary files /dev/null and b/assets/nightly/nightly-web-favicon.ico differ diff --git a/assets/nightly/nightly-windows.ico b/assets/nightly/nightly-windows.ico new file mode 100644 index 0000000000000..b6a0b43b93d2e Binary files /dev/null and b/assets/nightly/nightly-windows.ico differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg b/assets/prod/app-icon.icon/Assets/text.svg similarity index 100% rename from apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg rename to assets/prod/app-icon.icon/Assets/text.svg diff --git a/apps/mobile/assets/icon-composer-prod.icon/icon.json b/assets/prod/app-icon.icon/icon.json similarity index 88% rename from apps/mobile/assets/icon-composer-prod.icon/icon.json rename to assets/prod/app-icon.icon/icon.json index 8f7579311f267..8c12c022ef86d 100644 --- a/apps/mobile/assets/icon-composer-prod.icon/icon.json +++ b/assets/prod/app-icon.icon/icon.json @@ -6,8 +6,8 @@ { "layers": [ { - "image-name": "T3.svg", - "name": "T3", + "image-name": "text.svg", + "name": "Text", "position": { "scale": 10, "translation-in-points": [0, 0] diff --git a/assets/prod/black-ios-1024.png b/assets/prod/black-ios-1024.png index 2ff311ce786f0..a8a458337ab05 100644 Binary files a/assets/prod/black-ios-1024.png and b/assets/prod/black-ios-1024.png differ diff --git a/assets/prod/black-macos-1024.png b/assets/prod/black-macos-1024.png index 0a6e1cbfcff34..f5b68b1a6d407 100644 Binary files a/assets/prod/black-macos-1024.png and b/assets/prod/black-macos-1024.png differ diff --git a/assets/prod/black-universal-1024.png b/assets/prod/black-universal-1024.png index 88317dd41fbab..a8a458337ab05 100644 Binary files a/assets/prod/black-universal-1024.png and b/assets/prod/black-universal-1024.png differ diff --git a/assets/prod/t3-black-web-apple-touch-180.png b/assets/prod/t3-black-web-apple-touch-180.png index 9c593ce352ce0..2e30aecbc72b1 100644 Binary files a/assets/prod/t3-black-web-apple-touch-180.png and b/assets/prod/t3-black-web-apple-touch-180.png differ diff --git a/assets/prod/t3-black-web-favicon-16x16.png b/assets/prod/t3-black-web-favicon-16x16.png index f85017caf75f8..c175f2c87a216 100644 Binary files a/assets/prod/t3-black-web-favicon-16x16.png and b/assets/prod/t3-black-web-favicon-16x16.png differ diff --git a/assets/prod/t3-black-web-favicon-32x32.png b/assets/prod/t3-black-web-favicon-32x32.png index fae2d285b7a35..fe6fb545b8593 100644 Binary files a/assets/prod/t3-black-web-favicon-32x32.png and b/assets/prod/t3-black-web-favicon-32x32.png differ diff --git a/assets/prod/t3-black-web-favicon.ico b/assets/prod/t3-black-web-favicon.ico index e3ab4ae5e0248..a0fe86da92f0d 100644 Binary files a/assets/prod/t3-black-web-favicon.ico and b/assets/prod/t3-black-web-favicon.ico differ diff --git a/assets/prod/t3-black-windows.ico b/assets/prod/t3-black-windows.ico index e3ab4ae5e0248..a0fe86da92f0d 100644 Binary files a/assets/prod/t3-black-windows.ico and b/assets/prod/t3-black-windows.ico differ diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md new file mode 100644 index 0000000000000..2c36ab9d00866 --- /dev/null +++ b/docs/operations/mobile-app-store-screenshots.md @@ -0,0 +1,159 @@ +# Mobile app-store screenshot harness + +The screenshot harness runs the real mobile application against three disposable local T3 +environments. It creates an isolated base directory and server for each environment, real Git +projects with deterministic content, seeded orchestration projections, and persisted terminal +history. The app pairs with every server through its normal connection flow and React Navigation +opens the production Home, Thread, ThreadTerminal, ThreadReview, and SettingsEnvironments routes. + +No screenshot-specific screen recreates application UI. `EXPO_PUBLIC_SHOWCASE=1` only enables the +non-rendering pairing/readiness coordinator, disables terminal autofocus so captures do not contain +the software keyboard, and supplies deterministic T3 Connect discovery rows to the real +Environments screen. The local environment cards always come from real paired servers. + +## Capture the default matrix + +From the repository root: + + pnpm screenshots:mobile + +The command: + +1. Creates three temporary T3 base directories and starts a local server for each on an available + port. +2. Creates T3 Code, React, and Linux Git repositories with recognizable favicons, feature branches, + and a deterministic T3 Code review diff. +3. Seeds each server's migrated SQLite database with playful threads, messages, activities, and + terminal history, then adds two persisted mobile-outbox tasks waiting to send. +4. Starts an isolated Metro server, builds the selected native apps, and boots each device. +5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. +6. Navigates to the real application route for every requested scene. +7. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and + validates dimensions, aspect ratio, file size, and screenshot count before succeeding. +8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded + directly to App Store Connect or Google Play Console. + +The servers, Metro, temporary root directory, and devices started by the runner are cleaned up after +capture. Pass `--keep-running` to retain them for inspection; the runner prints the base-directory +paths and server ports. + +Captures wait for the real environment snapshot to hydrate and for the requested route to become +active. Both platforms record readiness in the simulator/emulator app container. A final settle +delay allows native terminal and Git review data to finish rendering. + +A full capture regenerates the selected native project with Expo's clean development prebuild before +building it. Use --skip-build for repeated captures after the first build. + +The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another +worktree cannot accidentally provide the bundle being photographed. + +The default matrix is: + +| Output folder | Capture target | Upload dimensions | Store slot | +| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- | +| `apple/iphone-6.9/{light,dark}/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | +| `apple/iphone-6.5/{light,dark}/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | +| `apple/ipad-13/{light,dark}/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | +| `google-play/phone/{light,dark}/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | +| `google-play/tablet-7/{light,dark}/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | +| `google-play/tablet-10/{light,dark}/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | + +Each target captures thread, terminal, review, thread list, and environments, producing 30 PNG +files for one appearance or 60 for both. Each appearance folder's five screenshots satisfy the configured Apple limit of 1–10, Google +phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. + +The generated tree is deliberately aligned with the store upload fields: + + artifacts/app-store/screenshots/ + ├── apple/ + │ ├── iphone-6.9/{light,dark}/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.5/{light,dark}/{thread,terminal,review,threads,environments}.png + │ └── ipad-13/{light,dark}/{thread,terminal,review,threads,environments}.png + └── google-play/ + ├── phone/{light,dark}/{thread,terminal,review,threads,environments}.png + ├── tablet-7/{light,dark}/{thread,terminal,review,threads,environments}.png + └── tablet-10/{light,dark}/{thread,terminal,review,threads,environments}.png + +Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD +names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. + +## Capture in GitHub Actions + +Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or +`android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and +runs iOS and Android concurrently: iPhone and iPad capture on a +12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a +16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. + +Every job uploads its PNGs even when a later capture fails, which makes partial runs useful for +diagnosis. Download `app-store-connect-screenshots` and `google-play-screenshots` from the workflow +run's Artifacts section. Each job runs validation again immediately before upload. Artifacts are +retained for 14 days. + +The workflow uses the same checked-in device and scene matrix as local capture. Android remains +ARM64 by default for local Apple Silicon development; CI sets `T3_SHOWCASE_ANDROID_ABI=x86_64` so the +debug APK matches its accelerated emulator. + +## Fast iteration + +Capture one scene or device: + + pnpm screenshots:mobile --device iphone-6.9 --scene thread + pnpm screenshots:mobile --platform android --scene review + +Override the configured appearance or capture both variants: + + pnpm screenshots:mobile --appearance light + pnpm screenshots:mobile --appearance dark + pnpm screenshots:mobile --appearance both + +Reuse the native build and retain the disposable environment: + + pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running + +Run Metro separately: + + pnpm --filter @t3tools/mobile showcase + pnpm screenshots:mobile --skip-build --skip-metro --device iphone-6.9 + +List the matrix and flags: + + pnpm screenshots:mobile --list + +Validate existing files without starting Metro, servers, simulators, or emulators: + + pnpm screenshots:mobile --validate-only + pnpm screenshots:mobile --platform ios --validate-only + +## Customize the seeded environment + +- Project repository, thread projections, conversation, terminal transcript, and Git changes: + [mobile-showcase-environment.ts](../../scripts/mobile-showcase-environment.ts) +- Device and capture matrix: + [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) +- Simulator/emulator orchestration: + [mobile-showcase.ts](../../scripts/mobile-showcase.ts) + +Fixture timestamps are generated relative to capture startup so every route shows stable relative +labels while the server still receives valid current data. The same deterministic three-environment +ensemble serves iPhone, iPad, Android phone, and Android tablet captures; responsive differences +come entirely from the production app layout. + +The Pending rows use the production offline outbox and point at the real T3 Code and React fixture +projects. Showcase coordination holds those two entries in the outbox for capture, just like a task +currently open for editing, so reconnecting the seeded environments cannot deliver and remove them +before the screenshot is taken. + +The Environments capture presents the three local fixture transports as a Tailscale HTTPS hostname, +a Helsinki VPS hostname, and a Tailnet IPv4 address. This display-only substitution keeps the cards +remote-first while the harness retains reliable loopback connections to its ephemeral servers. + +## Local prerequisites + +- iOS: Xcode command-line tools, the configured simulator runtimes, and installed CocoaPods. +- Android: ANDROID_HOME (or the default macOS SDK path), adb, emulator, and the configured AVD. + +The harness is the source of truth for upload dimensions; do not resize its output. If store rules +change, update the target's `storeAsset` specification. Capture fails when a PNG is the wrong size, +has alpha, is not 8-bit RGB, exceeds the configured file-size limit, violates Google Play's 9:16 +shape/bounds, or leaves a full output set below its store minimum. diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index d4d2b96869ee9..6bdea26666521 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -1,29 +1,29 @@ # Scripts -- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode. -- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). -- `bun run dev:web` — Starts just the Vite dev server for the web app. -- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. +- `vp run dev` — Starts contracts, server, and web in watch mode. +- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. +- `vp run dev:web` — Starts just the Vite dev server for the web app. +- Dev commands default `T3CODE_HOME` to `~/.t3` — the same shared home the desktop/production app uses. Override with `--home-dir` (see below) to keep dev state separate. - Override server CLI-equivalent flags from root dev commands with `--`, for example: - `bun run dev -- --base-dir ~/.t3-2` -- `bun run start` — Runs the production server (serves built web app as static files). -- `bun run build` — Builds contracts, web app, and server through Turbo. -- `bun run typecheck` — Strict TypeScript checks for all packages. -- `bun run test` — Runs workspace tests. -- `bun run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. -- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. -- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. -- `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`. -- `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. + `vp run dev -- --home-dir ~/.t3-2` +- `vp run start` — Runs the production server (serves built web app as static files). +- `vp run build` — Builds contracts, web app, and server. +- `vp run typecheck` — Strict TypeScript checks for all packages. +- `vp run test` — Runs workspace tests. +- `vp run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. +- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. +- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. +- `vp run dist:desktop:linux` — Builds a Linux AppImage into `./release`. +- `vp run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. ## Desktop `.dmg` packaging notes - Default build is unsigned/not notarized for local sharing. -- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. +- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. - Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. -- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` +- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage` - To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. - Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from @@ -38,8 +38,8 @@ Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. -- Default ports: server `3773`, web `5733` +- Default ports: server `13773`, web `5733` - Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) -- Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` +- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset. diff --git a/package.json b/package.json index 5e5365d2c1a03..3c0a8cc6b775d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "start:desktop": "vp run --filter @t3tools/desktop start", "start:marketing": "vp run --filter @t3tools/marketing preview", "start:mock-update-server": "node scripts/mock-update-server.ts", + "screenshots:mobile": "node scripts/mobile-showcase.ts", + "icons:export": "node scripts/export-brand-icons.ts", + "icons:check": "node scripts/export-brand-icons.ts --check", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts new file mode 100644 index 0000000000000..dd6f8c3a295e9 --- /dev/null +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; + +type TestThread = { readonly id: string } & ThreadSortInput; + +function makeThread(overrides: Partial = {}): TestThread { + return { + id: "thread-1", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + messages: [], + latestUserMessageAt: null, + ...overrides, + }; +} + +describe("sortThreads", () => { + it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => { + const sorted = sortThreads( + [ + makeThread({ + id: "thread-1", + latestUserMessageAt: "not-a-date", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: "thread-2", + latestUserMessageAt: "still-not-a-date", + createdAt: "invalid-created-at", + updatedAt: "invalid-updated-at", + }), + makeThread({ + id: "thread-3", + latestUserMessageAt: "invalid-latest-user-message-at", + createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "invalid-updated-at", + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual(["thread-3", "thread-1", "thread-2"]); + }); + + it("falls back to the latest valid user message when latestUserMessageAt is invalid", () => { + const sorted = sortThreads( + [ + makeThread({ + id: "thread-1", + latestUserMessageAt: "invalid-latest-user-message-at", + updatedAt: "2026-03-09T10:00:00.000Z", + messages: [ + { role: "user", createdAt: "2026-03-09T10:05:00.000Z" }, + { role: "assistant", createdAt: "2026-03-09T10:30:00.000Z" }, + { role: "user", createdAt: "2026-03-09T10:20:00.000Z" }, + ], + }), + makeThread({ + id: "thread-2", + createdAt: "2026-03-09T10:15:00.000Z", + updatedAt: "2026-03-09T10:15:00.000Z", + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 154d5f346b97f..5c372bafc96de 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -33,7 +33,10 @@ function getFirstSortableTimestamp(...values: Array): function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + const latestUserMessageTimestamp = toSortableTimestamp(thread.latestUserMessageAt); + if (latestUserMessageTimestamp !== null) { + return latestUserMessageTimestamp; + } } let latestUserMessageTimestamp: number | null = null; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a48fac21721a6..4e5b1e24df034 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -213,6 +213,7 @@ export interface DesktopUpdateState { runningUnderArm64Translation: boolean; availableVersion: string | null; downloadedVersion: string | null; + releaseNotes: ReadonlyArray; downloadPercent: number | null; checkedAt: string | null; message: string | null; @@ -220,6 +221,16 @@ export interface DesktopUpdateState { canRetry: boolean; } +export interface DesktopUpdateReleaseNote { + version: string; + items: ReadonlyArray; +} + +export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ + version: Schema.String, + items: Schema.Array(Schema.String), +}); + export const DesktopUpdateStateSchema = Schema.Struct({ enabled: Schema.Boolean, status: DesktopUpdateStatusSchema, @@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ runningUnderArm64Translation: Schema.Boolean, availableVersion: Schema.NullOr(Schema.String), downloadedVersion: Schema.NullOr(Schema.String), + releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), downloadPercent: Schema.NullOr(Schema.Number), checkedAt: Schema.NullOr(Schema.String), message: Schema.NullOr(Schema.String), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d5323728f01a1..d81d2f8004fe3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -222,10 +222,10 @@ export const ClaudeSettings = makeProviderSettingsSchema( homePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ - title: "Claude HOME path", + title: "CLAUDE_CONFIG_DIR path", description: - "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.", - providerSettingsForm: { placeholder: "~", clearWhenEmpty: "omit" }, + "Custom Claude home and config directory. Keeps .claude.json and .claude separate.", + providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, }), ), customModels: Schema.Array(Schema.String).pipe( @@ -256,11 +256,11 @@ export const CursorSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - binaryPath: makeBinaryPathSetting("agent").pipe( + binaryPath: makeBinaryPathSetting("cursor-agent").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Cursor agent binary.", - providerSettingsForm: { placeholder: "agent", clearWhenEmpty: "omit" }, + providerSettingsForm: { placeholder: "cursor-agent", clearWhenEmpty: "omit" }, }), ), apiEndpoint: TrimmedString.pipe( diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 31dc165e28c79..ccab07a1e9dec 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -116,6 +116,70 @@ describe("remote", () => { }); }); + it("treats a protocol-relative host as https", () => { + expect( + resolveRemotePairingTarget({ + host: "//remote.example.com", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + }); + }); + + it("preserves the port when normalizing a protocol-relative host", () => { + expect( + resolveRemotePairingTarget({ + host: "//remote.example.com:3000", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://remote.example.com:3000/", + wsBaseUrl: "wss://remote.example.com:3000/", + }); + }); + + it("normalizes a protocol-relative host from a hosted pairing link", () => { + expect( + resolveRemotePairingTarget({ + pairingUrl: "https://app.t3.codes/pair?host=%2F%2Fremote.example.com#token=pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + }); + }); + + it("collapses extra leading slashes instead of producing an empty host", () => { + expect( + resolveRemotePairingTarget({ + host: "///example.com", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://example.com/", + wsBaseUrl: "wss://example.com/", + }); + }); + + it("does not double-prepend https when the host already carries a scheme", () => { + expect( + resolveRemotePairingTarget({ + host: "//https://example.com", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://example.com/", + wsBaseUrl: "wss://example.com/", + }); + }); + it("preserves host ports when normalizing a bare host input", () => { expect( resolveRemotePairingTarget({ diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 8bdbc887b1d10..724d64532f15d 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -84,10 +84,10 @@ const normalizeRemoteBaseUrl = ( throw new RemoteBackendUrlMissingError(); } - const normalizedInput = - /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") - ? trimmed - : `https://${trimmed}`; + const withoutLeadingSlashes = trimmed.replace(/^\/+/, ""); + const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(withoutLeadingSlashes) + ? withoutLeadingSlashes + : `https://${withoutLeadingSlashes}`; let url: URL; try { url = new URL(normalizedInput); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9005f97dafe92..1a78a8ad7831c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-FV3+2NW/9MFbunJaPsMxvO0LAzyfccoPtPiKoIXAwUU= +packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -192,7 +192,7 @@ importers: version: 0.7.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) '@clerk/expo': specifier: 3.7.2 - version: 3.7.2(expo-auth-session@56.0.14(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))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(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)))(expo@56.0.12)(react-dom@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@19.2.3) + version: 3.7.2(expo-auth-session@56.0.14(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))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(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)))(expo@56.0.12)(react-dom@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@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -922,6 +922,9 @@ importers: effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + pngjs: + specifier: 7.0.0 + version: 7.0.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -929,6 +932,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@types/pngjs': + specifier: 6.0.5 + version: 6.0.5 vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -4918,6 +4924,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -11529,11 +11538,12 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.7.2(expo-auth-session@56.0.14(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))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(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)))(expo@56.0.12)(react-dom@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@19.2.3)': + '@clerk/expo@3.7.2(expo-auth-session@56.0.14(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))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(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)))(expo@56.0.12)(react-dom@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@19.2.3)(typescript@6.0.3)': dependencies: '@clerk/clerk-js': 6.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.25.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) base-64: 1.0.0 expo: 56.0.12(8895228379997a2a064f9644cda56ed0) react: 19.2.3 @@ -11547,6 +11557,9 @@ snapshots: expo-secure-store: 56.0.4(expo@56.0.12) expo-web-browser: 56.0.5(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-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - supports-color + - typescript '@clerk/react@6.12.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -14982,6 +14995,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 24.12.4 + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ad6056566b04..97a66edaaab62 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -92,6 +92,9 @@ overrides: yaml: "catalog:" packageExtensions: + "@clerk/expo@*": + dependencies: + "@expo/config-plugins": 56.0.9 "@effect/vitest@*": dependencies: vite-plus: "catalog:" diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts new file mode 100644 index 0000000000000..c24169dc2e139 --- /dev/null +++ b/scripts/export-brand-icons.ts @@ -0,0 +1,800 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts"; + +const DESIGN_GENERATION = 26; +const ICON_COMPOSER_EXECUTABLE_PARTS = [ + "Contents", + "Applications", + "Icon Composer.app", + "Contents", + "Executables", + "ictool", +] as const; +const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [ + "Icon Composer.app", + "Contents", + "Executables", + "ictool", +] as const; +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const IconComposerVersion = Schema.Struct({ + "bundle-version": Schema.NonEmptyString, + "short-bundle-version": Schema.NonEmptyString, +}); +const decodeIconComposerVersion = Schema.decodeUnknownEffect( + Schema.fromJsonString(IconComposerVersion), +); + +type IconPlatform = "iOS"; + +interface VariantOutputs { + readonly ios: string; + readonly macos: string; + readonly universal: string; + readonly appleTouch: string; + readonly favicon16: string; + readonly favicon32: string; + readonly faviconIco: string; + readonly windowsIco: string; +} + +interface IconVariant { + readonly label: string; + readonly source: string; + readonly outputs: VariantOutputs; +} + +interface IconComposerTool { + readonly path: string; + readonly version: string; + readonly bundleVersion: string; + readonly supportsDesignGeneration: boolean; +} + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +export class IconExportFileSystemError extends Schema.TaggedErrorClass()( + "IconExportFileSystemError", + { + operation: Schema.Literals([ + "resolve-repository-root", + "check-path", + "read-directory", + "read-file", + "make-directory", + "make-temp-directory", + "make-temp-file", + "write-file", + "rename-file", + ]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`; + } +} + +export class IconExportProcessError extends Schema.TaggedErrorClass()( + "IconExportProcessError", + { + operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]), + command: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Icon export process operation '${this.operation}' failed for ${this.command}.`; + } +} + +export class IconExportCommandFailedError extends Schema.TaggedErrorClass()( + "IconExportCommandFailedError", + { + command: Schema.String, + argumentCount: NonNegativeInt, + exitCode: Schema.Int, + sourcePath: Schema.String, + size: Schema.Int, + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`; + } +} + +export class IconExportToolResolutionError extends Schema.TaggedErrorClass()( + "IconExportToolResolutionError", + { + reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]), + designGeneration: Schema.Int, + toolPath: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + }, +) { + override get message(): string { + switch (this.reason) { + case "configured-invalid": + return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`; + case "configured-outdated": + return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`; + case "not-found": + return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`; + } + } +} + +export class IconExportSourceMissingError extends Schema.TaggedErrorClass()( + "IconExportSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing Icon Composer source project: ${this.sourcePath}`; + } +} + +export class IconExportRenditionError extends Schema.TaggedErrorClass()( + "IconExportRenditionError", + { + sourcePath: Schema.String, + outputPath: Schema.String, + expectedSize: Schema.Int, + actualWidth: Schema.optional(Schema.Int), + actualHeight: Schema.optional(Schema.Int), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const actual = + this.actualWidth === undefined || this.actualHeight === undefined + ? "an invalid PNG" + : `${this.actualWidth}x${this.actualHeight}`; + return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`; + } +} + +export class IconExportEncodingError extends Schema.TaggedErrorClass()( + "IconExportEncodingError", + { + variant: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode ICO renditions for the ${this.variant} icon.`; + } +} + +export class IconExportAssetsStaleError extends Schema.TaggedErrorClass()( + "IconExportAssetsStaleError", + { + paths: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`; + } +} + +const ICON_VARIANTS = [ + { + label: "development", + source: BRAND_ASSET_PATHS.developmentIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.developmentIosIconPng, + macos: BRAND_ASSET_PATHS.developmentDesktopIconPng, + universal: BRAND_ASSET_PATHS.developmentUniversalIconPng, + appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco, + }, + }, + { + label: "preview", + source: BRAND_ASSET_PATHS.nightlyIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.nightlyIosIconPng, + macos: BRAND_ASSET_PATHS.nightlyMacIconPng, + universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng, + appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + }, + }, + { + label: "production", + source: BRAND_ASSET_PATHS.productionIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.productionIosIconPng, + macos: BRAND_ASSET_PATHS.productionMacIconPng, + universal: BRAND_ASSET_PATHS.productionLinuxIconPng, + appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco, + }, + }, +] as const satisfies ReadonlyArray; + +const MACOS_EXPORT_CODEX_PROMPT = [ + "Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.", + "For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:", + ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`), + "Do not resize, composite, or otherwise post-process the exported PNGs.", + "Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.", +]; + +const RepositoryRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "resolve-repository-root", + path: new URL("..", import.meta.url).pathname, + cause, + }), + ), +); + +const collectStreamAsString = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const runCommand = Effect.fn("iconExport.runCommand")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "spawn", + command, + argumentCount: args.length, + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "collect-stdout", + command, + argumentCount: args.length, + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "collect-stderr", + command, + argumentCount: args.length, + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "wait-for-exit", + command, + argumentCount: args.length, + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, exitCode } satisfies CommandResult; +}); + +const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) => + path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1)); + +const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")( + function* () { + const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option); + return Option.flatMap(result, (output) => { + const developerDirectory = output.stdout.trim(); + return output.exitCode === 0 && developerDirectory.length > 0 + ? Option.some(developerDirectory) + : Option.none(); + }); + }, +); + +const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* ( + directory: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + Effect.orElseSucceed(() => []), + ); + return entries + .filter((entry) => /^Xcode.*\.app$/.test(entry)) + .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS)); +}); + +const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* ( + candidate: string, +) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(candidate).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: candidate, + cause, + }), + ), + Effect.orElseSucceed(() => false), + ); + if (!exists) return Option.none(); + + const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option); + if (Option.isNone(result) || result.value.exitCode !== 0) { + return Option.none(); + } + + const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option); + if (Option.isNone(version)) return Option.none(); + + const bundleVersion = version.value["bundle-version"]; + const shortVersion = version.value["short-bundle-version"]; + return Option.some({ + path: candidate, + version: `${shortVersion} (${bundleVersion})`, + bundleVersion, + supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2, + }); +}); + +const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () { + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const configuredTool = environment.ICON_COMPOSER_TOOL?.trim(); + if (configuredTool) { + const tool = yield* probeIconComposerTool(configuredTool); + if (Option.isNone(tool)) { + return yield* new IconExportToolResolutionError({ + reason: "configured-invalid", + designGeneration: DESIGN_GENERATION, + toolPath: configuredTool, + }); + } + if (!tool.value.supportsDesignGeneration) { + return yield* new IconExportToolResolutionError({ + reason: "configured-outdated", + designGeneration: DESIGN_GENERATION, + toolPath: configuredTool, + version: tool.value.version, + }); + } + return tool.value; + } + + const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory(); + const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim(); + const homeDirectory = environment.HOME?.trim(); + const searchDirectories = [ + "/Applications", + ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []), + ]; + const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, { + concurrency: "unbounded", + }); + const candidates = new Set([ + ...(configuredDeveloperDirectory + ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)] + : []), + ...Option.match(selectedDeveloperDirectory, { + onNone: () => [], + onSome: (developerDirectory) => [ + iconComposerToolFromDeveloperDirectory(developerDirectory, path), + ], + }), + path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS), + ...(homeDirectory + ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)] + : []), + ...xcodeCandidates.flat(), + ]); + const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, { + concurrency: "unbounded", + }); + const compatibleTools = probed + .filter(Option.isSome) + .map((tool) => tool.value) + .filter((tool) => tool.supportsDesignGeneration) + .sort((left, right) => + right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }), + ); + const newestTool = compatibleTools[0]; + if (newestTool) return newestTool; + + return yield* new IconExportToolResolutionError({ + reason: "not-found", + designGeneration: DESIGN_GENERATION, + }); +}); + +const renderIcon = Effect.fn("iconExport.renderIcon")(function* ( + toolPath: string, + sourcePath: string, + outputPath: string, + platform: IconPlatform, + size: number, +) { + const fs = yield* FileSystem.FileSystem; + const args = [ + sourcePath, + "--export-image", + "--output-file", + outputPath, + "--platform", + platform, + "--rendition", + "Default", + "--width", + String(size), + "--height", + String(size), + "--scale", + "1", + "--design-generation", + String(DESIGN_GENERATION), + ]; + const result = yield* runCommand(toolPath, args); + if (result.exitCode !== 0) { + return yield* new IconExportCommandFailedError({ + command: toolPath, + argumentCount: args.length, + exitCode: result.exitCode, + sourcePath, + size, + ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}), + ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}), + }); + } + + const contents = yield* fs.readFile(outputPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-file", + path: outputPath, + cause, + }), + ), + ); + const buffer = Buffer.from(contents); + const dimensions = yield* Effect.try({ + try: () => readPngDimensions(buffer), + catch: (cause) => + new IconExportRenditionError({ + sourcePath, + outputPath, + expectedSize: size, + cause, + }), + }); + if (dimensions.width !== size || dimensions.height !== size) { + return yield* new IconExportRenditionError({ + sourcePath, + outputPath, + expectedSize: size, + actualWidth: dimensions.width, + actualHeight: dimensions.height, + }); + } + return buffer; +}); + +const renderVariant = Effect.fn("iconExport.renderVariant")(function* ( + toolPath: string, + repositoryRoot: string, + temporaryDirectory: string, + variant: IconVariant, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourcePath = path.join(repositoryRoot, variant.source); + const sourceExists = yield* fs.exists(sourcePath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: sourcePath, + cause, + }), + ), + ); + if (!sourceExists) { + return yield* new IconExportSourceMissingError({ sourcePath: variant.source }); + } + + const renditionCache = new Map(); + const render = Effect.fn("iconExport.renderVariant.rendition")(function* ( + platform: IconPlatform, + size: number, + ) { + const cacheKey = `${platform}-${size}`; + const cached = renditionCache.get(cacheKey); + if (cached) return cached; + + const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`); + const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size); + renditionCache.set(cacheKey, contents); + return contents; + }); + + const ios = yield* render("iOS", 1024); + const icoRenditions = yield* Effect.forEach( + WINDOWS_ICON_SIZES, + (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))), + { concurrency: 1 }, + ); + const ico = yield* Effect.try({ + try: () => encodePngIco(icoRenditions), + catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }), + }); + + return new Map([ + [variant.outputs.ios, ios], + [variant.outputs.universal, ios], + [variant.outputs.appleTouch, yield* render("iOS", 180)], + [variant.outputs.favicon16, yield* render("iOS", 16)], + [variant.outputs.favicon32, yield* render("iOS", 32)], + [variant.outputs.faviconIco, ico], + [variant.outputs.windowsIco, ico], + ]); +}); + +const logManualMacOsExportInstructions = Effect.fn("iconExport.logManualMacOsExportInstructions")( + function* () { + yield* Console.warn( + [ + "macOS icons require Icon Composer's GUI-only pre-Tahoe preset and were not changed.", + "Export each source with Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, Scale: 1×:", + ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`), + "See assets/README.md for the complete workflow.", + "", + "Copy/paste this prompt into Codex to perform the native exports:", + "---", + ...MACOS_EXPORT_CODEX_PROMPT, + "---", + ].join("\n"), + ); + }, +); + +const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* ( + repositoryRoot: string, + relativePath: string, + contents: Buffer, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = path.join(repositoryRoot, relativePath); + const targetDirectory = path.dirname(targetPath); + yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-directory", + path: targetDirectory, + cause, + }), + ), + ); + const temporaryPath = yield* fs + .makeTempFileScoped({ + directory: targetDirectory, + prefix: ".t3-icon-export-", + suffix: ".tmp", + }) + .pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-temp-file", + path: targetDirectory, + cause, + }), + ), + ); + yield* fs.writeFile(temporaryPath, contents).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "write-file", + path: temporaryPath, + cause, + }), + ), + ); + yield* fs.rename(temporaryPath, targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "rename-file", + path: targetPath, + cause, + }), + ), + ); +}); + +const isCurrent = Effect.fn("iconExport.isCurrent")(function* ( + repositoryRoot: string, + relativePath: string, + expected: Buffer, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = path.join(repositoryRoot, relativePath); + const exists = yield* fs.exists(targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: targetPath, + cause, + }), + ), + ); + if (!exists) return false; + + const actual = yield* fs.readFile(targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-file", + path: targetPath, + cause, + }), + ), + ); + return Buffer.from(actual).equals(expected); +}); + +export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) { + const fs = yield* FileSystem.FileSystem; + const repositoryRoot = yield* RepositoryRoot; + const tool = yield* resolveIconComposerTool(); + const temporaryDirectory = yield* fs + .makeTempDirectoryScoped({ + prefix: "t3-icon-export-", + }) + .pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-temp-directory", + path: "system temporary directory", + cause, + }), + ), + ); + yield* Console.log( + `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`, + ); + + const generated = new Map(); + for (const variant of ICON_VARIANTS) { + yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`); + const variantAssets = yield* renderVariant( + tool.path, + repositoryRoot, + temporaryDirectory, + variant, + ); + for (const [relativePath, contents] of variantAssets) { + generated.set(relativePath, contents); + } + } + + if (checkOnly) { + const stale = yield* Effect.filter( + [...generated.entries()], + ([relativePath, contents]) => + isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)), + { concurrency: "unbounded" }, + ); + if (stale.length > 0) { + return yield* new IconExportAssetsStaleError({ + paths: stale.map(([relativePath]) => relativePath), + }); + } + yield* Console.log(`All ${generated.size} generated icon assets are current.`); + yield* logManualMacOsExportInstructions(); + return; + } + + yield* Effect.forEach( + generated, + ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents), + { concurrency: 1, discard: true }, + ); + yield* Console.log(`Updated ${generated.size} generated icon assets.`); + yield* logManualMacOsExportInstructions(); +}); + +export const exportBrandIconsCommand = Command.make( + "export-brand-icons", + { + check: Flag.boolean("check").pipe( + Flag.withDescription("Verify generated icon assets without modifying files."), + Flag.withDefault(false), + ), + }, + ({ check }) => exportBrandIcons(check).pipe(Effect.scoped), +).pipe( + Command.withDescription( + "Export development, preview, and production assets from Icon Composer projects.", + ), +); + +if (import.meta.main) { + Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts index 8e1875e8d274f..6ab3c6a3a2932 100644 --- a/scripts/lib/brand-assets.test.ts +++ b/scripts/lib/brand-assets.test.ts @@ -55,4 +55,19 @@ describe("brand-assets", () => { expect(resolveWebAssetBrandForChannel("latest")).toBe("production"); expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly"); }); + + it("keeps development, nightly, and production icon families separate", () => { + expect([ + BRAND_ASSET_PATHS.developmentIconComposerProject, + BRAND_ASSET_PATHS.nightlyIconComposerProject, + BRAND_ASSET_PATHS.productionIconComposerProject, + ]).toEqual([ + "assets/dev/app-icon.icon", + "assets/nightly/app-icon.icon", + "assets/prod/app-icon.icon", + ]); + expect(BRAND_ASSET_PATHS.developmentDesktopIconPng).toMatch(/^assets\/dev\/blueprint-/); + expect(BRAND_ASSET_PATHS.nightlyMacIconPng).toMatch(/^assets\/nightly\/nightly-/); + expect(BRAND_ASSET_PATHS.productionMacIconPng).toMatch(/^assets\/prod\/black-/); + }); }); diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 87b0cab219eb3..fe02c975ea3c5 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -1,4 +1,10 @@ export const BRAND_ASSET_PATHS = { + developmentIconComposerProject: "assets/dev/app-icon.icon", + developmentIosIconPng: "assets/dev/blueprint-ios-1024.png", + developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png", + + productionIconComposerProject: "assets/prod/app-icon.icon", + productionIosIconPng: "assets/prod/black-ios-1024.png", productionMacIconPng: "assets/prod/black-macos-1024.png", productionLinuxIconPng: "assets/prod/black-universal-1024.png", productionWindowsIconIco: "assets/prod/t3-black-windows.ico", @@ -7,13 +13,15 @@ export const BRAND_ASSET_PATHS = { productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png", productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png", - nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", - nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", - nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", - nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico", - nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png", - nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png", - nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png", + nightlyIconComposerProject: "assets/nightly/app-icon.icon", + nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png", + nightlyMacIconPng: "assets/nightly/nightly-macos-1024.png", + nightlyLinuxIconPng: "assets/nightly/nightly-universal-1024.png", + nightlyWindowsIconIco: "assets/nightly/nightly-windows.ico", + nightlyWebFaviconIco: "assets/nightly/nightly-web-favicon.ico", + nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png", + nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png", + nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png", developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", diff --git a/scripts/lib/icon-export.test.ts b/scripts/lib/icon-export.test.ts new file mode 100644 index 0000000000000..3db6f22cbdbcc --- /dev/null +++ b/scripts/lib/icon-export.test.ts @@ -0,0 +1,47 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { encodePngIco, readPngDimensions } from "./icon-export.ts"; + +const pngHeader = (width: number, height: number) => { + const contents = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(contents); + contents.write("IHDR", 12, "ascii"); + contents.writeUInt32BE(width, 16); + contents.writeUInt32BE(height, 20); + return contents; +}; + +describe("icon export", () => { + it("reads dimensions from a PNG IHDR chunk", () => { + assert.deepEqual(readPngDimensions(pngHeader(1024, 512)), { width: 1024, height: 512 }); + }); + + it("encodes PNG renditions into an ICO directory", () => { + const small = pngHeader(16, 16); + const large = pngHeader(256, 256); + const ico = encodePngIco([ + { size: 16, contents: small }, + { size: 256, contents: large }, + ]); + + assert.equal(ico.readUInt16LE(2), 1); + assert.equal(ico.readUInt16LE(4), 2); + assert.equal(ico.readUInt8(6), 16); + assert.equal(ico.readUInt8(22), 0); + assert.equal(ico.readUInt32LE(18), 38); + assert.equal(ico.readUInt32LE(34), 38 + small.length); + assert.deepEqual(ico.subarray(38, 38 + small.length), small); + assert.deepEqual(ico.subarray(38 + small.length), large); + }); + + it("rejects duplicate ICO rendition sizes", () => { + assert.throws( + () => + encodePngIco([ + { size: 32, contents: pngHeader(32, 32) }, + { size: 32, contents: pngHeader(32, 32) }, + ]), + /provided more than once/, + ); + }); +}); diff --git a/scripts/lib/icon-export.ts b/scripts/lib/icon-export.ts new file mode 100644 index 0000000000000..f48228a94fd32 --- /dev/null +++ b/scripts/lib/icon-export.ts @@ -0,0 +1,72 @@ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const; + +export interface PngIconImage { + readonly size: number; + readonly contents: Buffer; +} + +export function readPngDimensions(contents: Buffer): { + readonly width: number; + readonly height: number; +} { + if ( + contents.length < 24 || + !contents.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || + contents.toString("ascii", 12, 16) !== "IHDR" + ) { + throw new Error("Icon Composer produced an invalid PNG."); + } + + return { + width: contents.readUInt32BE(16), + height: contents.readUInt32BE(20), + }; +} + +/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */ +export function encodePngIco(images: ReadonlyArray): Buffer { + if (images.length === 0) { + throw new Error("An ICO file requires at least one PNG rendition."); + } + + const seenSizes = new Set(); + for (const image of images) { + if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) { + throw new Error(`ICO rendition size must be an integer from 1 to 256, got ${image.size}.`); + } + if (seenSizes.has(image.size)) { + throw new Error(`ICO rendition size ${image.size} was provided more than once.`); + } + if (image.contents.length === 0) { + throw new Error(`ICO rendition ${image.size}x${image.size} is empty.`); + } + seenSizes.add(image.size); + } + + const headerSize = 6; + const directoryEntrySize = 16; + const directorySize = directoryEntrySize * images.length; + const header = Buffer.alloc(headerSize + directorySize); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(images.length, 4); + + let imageOffset = header.length; + images.forEach((image, index) => { + const entryOffset = headerSize + index * directoryEntrySize; + const encodedSize = image.size === 256 ? 0 : image.size; + header.writeUInt8(encodedSize, entryOffset); + header.writeUInt8(encodedSize, entryOffset + 1); + header.writeUInt8(0, entryOffset + 2); + header.writeUInt8(0, entryOffset + 3); + header.writeUInt16LE(1, entryOffset + 4); + header.writeUInt16LE(32, entryOffset + 6); + header.writeUInt32LE(image.contents.length, entryOffset + 8); + header.writeUInt32LE(imageOffset, entryOffset + 12); + imageOffset += image.contents.length; + }); + + return Buffer.concat([header, ...images.map((image) => image.contents)]); +} diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts new file mode 100644 index 0000000000000..c42239e427f4b --- /dev/null +++ b/scripts/mobile-showcase-environment.ts @@ -0,0 +1,569 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeUtil from "node:util"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +export const SHOWCASE_PROJECT_ID = "t3code"; +export const SHOWCASE_THREAD_ID = "remote-command-center"; +export const SHOWCASE_TERMINAL_ID = "term-1"; + +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; + +const PROJECTOR_NAMES = [ + "projection.projects", + "projection.threads", + "projection.thread-messages", + "projection.thread-proposed-plans", + "projection.thread-activities", + "projection.thread-sessions", + "projection.thread-turns", + "projection.checkpoints", + "projection.pending-approvals", +] as const; + +const MODEL_SELECTION = JSON.stringify({ instanceId: "codex", model: "gpt-5.4" }); +const PROJECT_SCRIPTS = JSON.stringify([ + { + id: "dev", + name: "Dev", + command: "pnpm dev", + icon: "play", + runOnWorktreeCreate: false, + }, + { + id: "test", + name: "Tests", + command: "pnpm test", + icon: "test", + runOnWorktreeCreate: false, + }, +]); + +export const SHOWCASE_TERMINAL_BUFFER = [ + "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m", + "$ vp test run --changed", + "", + " \u001b[38;5;117mt3code-mobile\u001b[0m 184 passed", + " \u001b[38;5;213mclient-runtime\u001b[0m 263 passed", + " \u001b[38;5;221mserver\u001b[0m 165 passed", + "", + "\u001b[32m✨ 612 tests passed\u001b[0m · 3 environments online", + "", + "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m $ ", +].join("\r\n"); + +const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string { + return \`${"${count}"} environments\`; +} +`; + +const UPDATED_ENVIRONMENT_PRESENCE = `const PULSE = ["✦", "✧", "·", "✧"] as const; + +export function environmentLabel(connected: number, total: number, frame: number): string { + const pulse = PULSE[frame % PULSE.length]; + return \`${"${pulse} ${connected}/${total}"} ready\`; +} +`; + +const REMOTE_HANDOFF_CARD = `import { View, Text } from "react-native"; + +export function RemoteHandoffCard(props: { machine: string; latencyMs: number }) { + return ( + + Ready on {props.machine} + Handoff in {props.latencyMs}ms + + ); +} +`; + +const PROJECT_FAVICONS = { + t3code: ` + + +`, + react: ` + + + +`, + linux: ` + + + + + + +`, +} as const; + +export const SHOWCASE_PROJECTS = [ + { + id: "t3code", + title: "T3 Code", + directory: "t3code", + repositoryUrl: "https://github.com/pingdotgg/t3code.git", + favicon: PROJECT_FAVICONS.t3code, + }, + { + id: "react", + title: "React", + directory: "react", + repositoryUrl: "https://github.com/facebook/react.git", + favicon: PROJECT_FAVICONS.react, + }, + { + id: "linux", + title: "Linux", + directory: "linux", + repositoryUrl: "https://github.com/torvalds/linux.git", + favicon: PROJECT_FAVICONS.linux, + }, +] as const; + +export const SHOWCASE_ENVIRONMENTS = [ + { + id: "moonbase-terminal", + label: "Moonbase Terminal", + projectIds: ["t3code"], + }, + { + id: "suspense-station", + label: "Suspense Station", + projectIds: ["react"], + }, + { + id: "kernel-cabin", + label: "Kernel Cabin", + projectIds: ["linux"], + }, +] as const; + +export const SHOWCASE_THREADS = [ + { + id: SHOWCASE_THREAD_ID, + projectId: "t3code", + title: "Make remote coding feel local ✦", + branch: "feat/remote-command-center", + minutesAgo: 3, + request: + "Give T3 Code a remote-first command center. Make three machines feel one tap away, keep agent work in sync, and make every handoff feel instant.", + response: + "T3 Code now treats every machine like it is right here in the room. ✦\n\n- Moonbase, Suspense Station, and Kernel Cabin stay live together\n- Terminal state follows you without losing a single line\n- Agent work remains perfectly in sync across devices\n- Handoffs land before your train of thought can wander\n\nI also ran the changed workspace: **612 tests passed**.", + }, + { + id: "pocket-command-center", + projectId: "t3code", + title: "Put the command center in your pocket", + branch: "feat/pocket-command-center", + minutesAgo: 21, + state: "approval" as const, + request: "Make switching between desktop, phone, and tablet feel like one continuous session.", + response: + "The handoff flow preserves the selected thread, terminal buffer, and working diff. The final motion treatment is ready for approval.", + }, + { + id: "buttery-suspense", + projectId: "react", + title: "Make Suspense transitions buttery", + branch: "perf/buttery-suspense", + minutesAgo: 12, + state: "working" as const, + request: + "Trace the last few dropped frames in nested Suspense transitions and make them disappear.", + response: null, + }, + { + id: "hydration-haikus", + projectId: "react", + title: "Turn hydration warnings into haikus", + branch: "dev/hydration-haikus", + minutesAgo: 44, + request: + "Keep hydration errors precise, but make the development copy unexpectedly delightful.", + response: + "The diagnostics still lead with the exact mismatch and component stack. A tiny optional haiku now closes the expanded explanation.", + }, + { + id: "beautiful-boot", + projectId: "linux", + title: "Make boot logs oddly beautiful", + branch: "feat/beautiful-boot", + minutesAgo: 34, + state: "plan" as const, + request: + "Design a clearer boot timeline that remains useful over serial and never hides kernel detail.", + response: + "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.", + }, + { + id: "scheduler-breathe", + projectId: "linux", + title: "Let the scheduler breathe", + branch: "perf/scheduler-breathe", + minutesAgo: 76, + request: + "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.", + response: + "The new heuristic reduces needless migrations during short bursts while preserving the existing latency guardrails.", + }, +] as const; + +function minutesBefore(now: number, minutes: number): string { + return new Date(now - minutes * 60_000).toISOString(); +} + +async function runGit(workspaceRoot: string, args: ReadonlyArray): Promise { + await execFile("git", [...args], { + cwd: workspaceRoot, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Alex Rivera", + GIT_AUTHOR_EMAIL: "alex@lumen.test", + GIT_COMMITTER_NAME: "Alex Rivera", + GIT_COMMITTER_EMAIL: "alex@lumen.test", + }, + }); +} + +async function initializeRepository(input: { + readonly workspaceRoot: string; + readonly repositoryUrl: string; + readonly commitMessage: string; +}): Promise { + await runGit(input.workspaceRoot, ["init", "-b", "main"]); + await runGit(input.workspaceRoot, ["remote", "add", "origin", input.repositoryUrl]); + await runGit(input.workspaceRoot, ["add", "."]); + await runGit(input.workspaceRoot, ["commit", "-m", input.commitMessage]); +} + +async function seedT3CodeWorkspace(workspaceRoot: string): Promise { + await NodeFSP.mkdir(NodePath.join(workspaceRoot, "apps/mobile/src/features/home"), { + recursive: true, + }); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "package.json"), + `${JSON.stringify({ name: "t3code", private: true, scripts: { test: "vp test" } }, null, 2)}\n`, + ); + await NodeFSP.writeFile(NodePath.join(workspaceRoot, "favicon.svg"), PROJECT_FAVICONS.t3code); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"), + BASE_ENVIRONMENT_PRESENCE, + ); + await initializeRepository({ + workspaceRoot, + repositoryUrl: "https://github.com/pingdotgg/t3code.git", + commitMessage: "Show connected environments", + }); + await runGit(workspaceRoot, ["checkout", "-b", "feat/remote-command-center"]); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/environmentPresence.ts"), + UPDATED_ENVIRONMENT_PRESENCE, + ); + await NodeFSP.writeFile( + NodePath.join(workspaceRoot, "apps/mobile/src/features/home/RemoteHandoffCard.tsx"), + REMOTE_HANDOFF_CARD, + ); +} + +async function seedCompanionWorkspace(input: { + readonly workspaceRoot: string; + readonly title: string; + readonly repositoryUrl: string; + readonly favicon: string; +}): Promise { + await NodeFSP.mkdir(input.workspaceRoot, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(input.workspaceRoot, "favicon.svg"), input.favicon); + await NodeFSP.writeFile( + NodePath.join(input.workspaceRoot, "README.md"), + `# ${input.title}\n\nSeeded by the T3 Code mobile screenshot harness.\n`, + ); + await initializeRepository({ + workspaceRoot: input.workspaceRoot, + repositoryUrl: input.repositoryUrl, + commitMessage: `Seed ${input.title} workspace`, + }); +} + +function insertThread( + database: NodeSqlite.DatabaseSync, + now: number, + input: { + readonly id: string; + readonly projectId: string; + readonly title: string; + readonly branch: string; + readonly minutesAgo: number; + readonly state?: "working" | "approval" | "plan"; + readonly workspaceRoot: string; + }, +): void { + const turnId = `${input.id}-turn`; + const updatedAt = minutesBefore(now, input.minutesAgo); + const isWorking = input.state === "working"; + database + .prepare( + `INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, + pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, + archived_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`, + ) + .run( + input.id, + input.projectId, + input.title, + MODEL_SELECTION, + "full-access", + input.state === "plan" ? "plan" : "default", + input.branch, + input.workspaceRoot, + turnId, + minutesBefore(now, input.minutesAgo + 1), + input.state === "approval" ? 1 : 0, + input.state === "plan" ? 1 : 0, + minutesBefore(now, input.minutesAgo + 120), + updatedAt, + ); + database + .prepare( + `INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, assistant_message_id, state, requested_at, + started_at, completed_at, checkpoint_turn_count, checkpoint_ref, checkpoint_status, + checkpoint_files_json, source_proposed_plan_thread_id, source_proposed_plan_id + ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, NULL, NULL, NULL, '[]', NULL, NULL)`, + ) + .run( + input.id, + turnId, + isWorking ? null : `${input.id}-answer`, + isWorking ? "running" : "completed", + minutesBefore(now, input.minutesAgo + 2), + minutesBefore(now, input.minutesAgo + 2), + isWorking ? null : updatedAt, + ); + database + .prepare( + `INSERT INTO projection_thread_sessions ( + thread_id, status, provider_name, provider_instance_id, provider_session_id, + provider_thread_id, runtime_mode, active_turn_id, last_error, updated_at + ) VALUES (?, ?, 'Codex', 'codex', NULL, NULL, 'full-access', ?, NULL, ?)`, + ) + .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); +} + +function seedDatabase( + dbPath: string, + workspaceRoots: ReadonlyMap, + projects: ReadonlyArray<(typeof SHOWCASE_PROJECTS)[number]>, + threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, + now: number, +): void { + const database = new NodeSqlite.DatabaseSync(dbPath); + try { + database.exec("BEGIN IMMEDIATE"); + for (const table of [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", + ]) { + database.exec(`DELETE FROM ${table}`); + } + const insertProject = database.prepare( + `INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, scripts_json, + created_at, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`, + ); + for (const [index, project] of projects.entries()) { + const workspaceRoot = workspaceRoots.get(project.id); + if (!workspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`); + const latestThreadMinutes = Math.min( + ...threads + .filter((thread) => thread.projectId === project.id) + .map((thread) => thread.minutesAgo), + ); + insertProject.run( + project.id, + project.title, + workspaceRoot, + MODEL_SELECTION, + PROJECT_SCRIPTS, + minutesBefore(now, 60 * 24 * (90 - index * 12)), + minutesBefore(now, latestThreadMinutes), + ); + } + + for (const thread of threads) { + const workspaceRoot = workspaceRoots.get(thread.projectId); + if (!workspaceRoot) throw new Error(`Missing workspace root for ${thread.projectId}.`); + insertThread(database, now, { + ...thread, + ...("state" in thread ? { state: thread.state } : {}), + workspaceRoot, + }); + } + + const insertMessage = database.prepare( + `INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, attachments_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, NULL, ?, ?)`, + ); + for (const thread of threads) { + const turnId = `${thread.id}-turn`; + const requestTime = minutesBefore(now, thread.minutesAgo + 5); + insertMessage.run( + `${thread.id}-request`, + thread.id, + turnId, + "user", + thread.request, + requestTime, + requestTime, + ); + if (thread.response !== null) { + const responseTime = minutesBefore(now, thread.minutesAgo); + insertMessage.run( + `${thread.id}-answer`, + thread.id, + turnId, + "assistant", + thread.response, + responseTime, + responseTime, + ); + } + } + + const turnId = `${SHOWCASE_THREAD_ID}-turn`; + const insertActivity = database.prepare( + `INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES (?, ?, ?, 'tool', 'tool.completed', ?, ?, ?, ?)`, + ); + insertActivity.run( + "trace-remote-handoff", + SHOWCASE_THREAD_ID, + turnId, + "Traced the remote handoff path", + JSON.stringify({ + itemType: "command_execution", + title: "Traced the remote handoff path", + detail: "Three environments, one continuous workspace", + status: "completed", + }), + 1, + minutesBefore(now, 8), + ); + insertActivity.run( + "sync-command-center", + SHOWCASE_THREAD_ID, + turnId, + "Synced the command center", + JSON.stringify({ + itemType: "file_change", + title: "Synced the command center", + detail: "2 files changed · instant handoffs · calm reconnects", + status: "completed", + }), + 2, + minutesBefore(now, 6), + ); + insertActivity.run( + "run-changed-suite", + SHOWCASE_THREAD_ID, + turnId, + "Ran the changed workspace", + JSON.stringify({ + itemType: "command_execution", + title: "Ran the changed workspace", + detail: "612 tests passed · 3 environments online", + status: "completed", + }), + 3, + minutesBefore(now, 4), + ); + + for (const [index, projector] of PROJECTOR_NAMES.entries()) { + database + .prepare( + "INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES (?, ?, ?)", + ) + .run(projector, index + 1, minutesBefore(now, 1)); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } finally { + database.close(); + } +} + +export async function seedShowcaseEnvironment(input: { + readonly baseDir: string; + readonly projectIds?: ReadonlyArray; + readonly now?: number; +}): Promise<{ readonly dbPath: string; readonly workspaceRoot: string }> { + const now = input.now ?? Date.now(); + const selectedProjectIds = new Set( + input.projectIds ?? SHOWCASE_PROJECTS.map((project) => project.id), + ); + const projects = SHOWCASE_PROJECTS.filter((project) => selectedProjectIds.has(project.id)); + if (projects.length === 0) throw new Error("At least one showcase project must be selected."); + const threads = SHOWCASE_THREADS.filter((thread) => selectedProjectIds.has(thread.projectId)); + const workspaceBase = NodePath.join(input.baseDir, "workspace"); + const workspaceRoots = new Map( + projects.map( + (project) => [project.id, NodePath.join(workspaceBase, project.directory)] as const, + ), + ); + const primaryProject = + projects.find((project) => project.id === SHOWCASE_PROJECT_ID) ?? projects[0]; + if (!primaryProject) throw new Error("The primary showcase workspace is not configured."); + const workspaceRoot = workspaceRoots.get(primaryProject.id); + if (!workspaceRoot) throw new Error("The primary showcase workspace is not configured."); + const dbPath = NodePath.join(input.baseDir, "userdata", "state.sqlite"); + if (primaryProject.id === SHOWCASE_PROJECT_ID) { + await seedT3CodeWorkspace(workspaceRoot); + } + await Promise.all( + projects + .filter((project) => project.id !== SHOWCASE_PROJECT_ID) + .map(async (project) => { + const projectWorkspaceRoot = workspaceRoots.get(project.id); + if (!projectWorkspaceRoot) throw new Error(`Missing workspace root for ${project.id}.`); + await seedCompanionWorkspace({ + workspaceRoot: projectWorkspaceRoot, + title: project.title, + repositoryUrl: project.repositoryUrl, + favicon: project.favicon, + }); + }), + ); + seedDatabase(dbPath, workspaceRoots, projects, threads, now); + + const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); + if (selectedProjectIds.has(SHOWCASE_PROJECT_ID)) { + const safeThreadId = Buffer.from(SHOWCASE_THREAD_ID).toString("base64url"); + await NodeFSP.mkdir(terminalDirectory, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(terminalDirectory, `terminal_${safeThreadId}.log`), + SHOWCASE_TERMINAL_BUFFER, + ); + } + return { dbPath, workspaceRoot }; +} diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts new file mode 100644 index 0000000000000..3237933bec8a0 --- /dev/null +++ b/scripts/mobile-showcase.config.ts @@ -0,0 +1,206 @@ +import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; + +export { SHOWCASE_SCENES }; +export type { ShowcaseScene }; + +export type ShowcaseAppearance = "light" | "dark"; + +export interface ShowcaseStoreAssetSpec { + readonly store: "apple" | "google-play"; + /** Device directory relative to ShowcaseConfig.outputDirectory. */ + readonly directory: string; + readonly width: number; + readonly height: number; + readonly minimumUploadCount: number; + readonly maximumUploadCount: number; + readonly maximumFileSizeBytes?: number; +} + +export interface ShowcaseIosDevice { + readonly id: string; + readonly platform: "ios"; + /** Exact name from `xcrun simctl list devices available`. */ + readonly simulator: string; + /** Device type used to create a disposable simulator when the named one is absent. */ + readonly simulatorDeviceType?: string; + /** Appearance used when the CLI does not pass --appearance. */ + readonly appearance: ShowcaseAppearance; + readonly scenes: ReadonlyArray; + readonly storeAsset: ShowcaseStoreAssetSpec; +} + +export interface ShowcaseAndroidDevice { + readonly id: string; + readonly platform: "android"; + /** Exact name from `emulator -list-avds`. */ + readonly avd: string; + /** Appearance used when the CLI does not pass --appearance. */ + readonly appearance: ShowcaseAppearance; + /** Native ABI used by the AVD, from its config.ini `abi.type`. */ + readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a"; + readonly scenes: ReadonlyArray; + /** Optional capture viewport. Omit to use the AVD's native size and density. */ + readonly viewport?: { + readonly width: number; + readonly height: number; + readonly density?: number; + }; + readonly storeAsset: ShowcaseStoreAssetSpec; +} + +export type ShowcaseDevice = ShowcaseIosDevice | ShowcaseAndroidDevice; + +export interface ShowcaseConfig { + readonly outputDirectory: string; + readonly metroPort: number; + readonly settleDelayMs: number; + readonly devices: ReadonlyArray; +} + +const ANDROID_ABIS = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"] as const; + +export function resolveShowcaseAndroidAbi( + value: string | undefined, +): NonNullable { + if (!value) return "arm64-v8a"; + if (ANDROID_ABIS.some((abi) => abi === value)) { + return value as NonNullable; + } + throw new Error( + `Unsupported T3_SHOWCASE_ANDROID_ABI '${value}'. Use ${ANDROID_ABIS.join(", ")}.`, + ); +} + +/** + * The defaults cover every App Store Connect and Google Play upload slot used + * by the mobile app. Edit this matrix (or pass --device / --scene) without + * changing the runner. Every target declares and validates its exact upload + * dimensions so SDK or emulator changes cannot silently produce invalid files. + */ +const config: ShowcaseConfig = { + outputDirectory: "artifacts/app-store/screenshots", + // Dedicated port so the harness cannot attach to a normal mobile dev server + // (or a second worktree) and capture the wrong bundle. + metroPort: 8199, + settleDelayMs: 2_500, + devices: [ + { + id: "iphone-6.9", + platform: "ios", + simulator: "iPhone 17 Pro Max", + simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", + appearance: "dark", + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "apple", + directory: "apple/iphone-6.9", + width: 1320, + height: 2868, + minimumUploadCount: 1, + maximumUploadCount: 10, + }, + }, + { + id: "iphone-6.5", + platform: "ios", + simulator: "T3 Showcase iPhone 14 Plus", + simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus", + appearance: "dark", + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "apple", + directory: "apple/iphone-6.5", + width: 1284, + height: 2778, + minimumUploadCount: 1, + maximumUploadCount: 10, + }, + }, + { + id: "ipad-13", + platform: "ios", + simulator: "iPad Pro 13-inch (M5)", + simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", + appearance: "dark", + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "apple", + directory: "apple/ipad-13", + width: 2064, + height: 2752, + minimumUploadCount: 1, + maximumUploadCount: 10, + }, + }, + { + id: "pixel", + platform: "android", + avd: "Pixel_10_Pro", + // Apple Silicon uses ARM64 locally; CI overrides this with x86_64 so its + // Blacksmith Linux runner can use KVM acceleration. + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), + appearance: "dark", + viewport: { + width: 1080, + height: 1920, + density: 420, + }, + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "google-play", + directory: "google-play/phone", + width: 1080, + height: 1920, + minimumUploadCount: 2, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, + }, + }, + { + id: "android-tablet-7", + platform: "android", + avd: "Pixel_10_Pro", + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), + appearance: "dark", + viewport: { + width: 1080, + height: 1920, + density: 288, + }, + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "google-play", + directory: "google-play/tablet-7", + width: 1080, + height: 1920, + minimumUploadCount: 4, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, + }, + }, + { + id: "android-tablet-10", + platform: "android", + avd: "Pixel_10_Pro", + abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), + appearance: "dark", + viewport: { + width: 1440, + height: 2560, + density: 288, + }, + scenes: ["thread", "terminal", "review", "threads", "environments"], + storeAsset: { + store: "google-play", + directory: "google-play/tablet-10", + width: 1440, + height: 2560, + minimumUploadCount: 4, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, + }, + }, + ], +}; + +export default config; diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts new file mode 100644 index 0000000000000..1eaefe5ad8630 --- /dev/null +++ b/scripts/mobile-showcase.test.ts @@ -0,0 +1,296 @@ +import { assert, it } from "@effect/vitest"; +import { PNG } from "pngjs"; + +import showcaseConfig, { + resolveShowcaseAndroidAbi, + type ShowcaseConfig, + type ShowcaseStoreAssetSpec, +} from "./mobile-showcase.config.ts"; +import { + SHOWCASE_ENVIRONMENTS, + SHOWCASE_PROJECTS, + SHOWCASE_THREADS, +} from "./mobile-showcase-environment.ts"; +import { + encodeAndroidPairingUrls, + normalizeStorePng, + parseShowcaseCliArgs, + parsePairingCredentialOutput, + planShowcaseCaptures, + readPngDimensions, + readPngMetadata, + resolveAndroidSdkRoot, + selectLanIpv4Address, + showcaseCaptureDirectory, + showcaseSceneUrl, + validateStoreAsset, + validateStoreAssetCount, +} from "./mobile-showcase.ts"; + +const appleSpec: ShowcaseStoreAssetSpec = { + store: "apple", + directory: "apple/iphone-test", + width: 1284, + height: 2778, + minimumUploadCount: 1, + maximumUploadCount: 10, +}; + +const googleSpec: ShowcaseStoreAssetSpec = { + store: "google-play", + directory: "google-play/phone", + width: 1080, + height: 1920, + minimumUploadCount: 2, + maximumUploadCount: 8, + maximumFileSizeBytes: 8 * 1024 * 1024, +}; + +const config: ShowcaseConfig = { + outputDirectory: "artifacts", + metroPort: 8199, + settleDelayMs: 1, + devices: [ + { + id: "phone", + platform: "ios", + simulator: "iPhone Test", + appearance: "dark", + scenes: ["thread", "review"], + storeAsset: appleSpec, + }, + { + id: "pixel", + platform: "android", + avd: "Pixel_Test", + appearance: "light", + scenes: ["thread", "terminal"], + storeAsset: googleSpec, + }, + ], +}; + +it("parses repeatable capture filters", () => { + const options = parseShowcaseCliArgs([ + "--platform", + "ios", + "--device", + "phone", + "--scene", + "review", + "--appearance", + "both", + "--skip-build", + ]); + assert.deepStrictEqual([...options.platforms], ["ios"]); + assert.deepStrictEqual([...options.deviceIds], ["phone"]); + assert.deepStrictEqual([...options.scenes], ["review"]); + assert.deepStrictEqual([...options.appearances], ["light", "dark"]); + assert.equal(options.skipBuild, true); +}); + +it("rejects unsupported system appearances", () => { + assert.throws( + () => parseShowcaseCliArgs(["--appearance", "sepia"]), + /Unsupported appearance 'sepia'/u, + ); +}); + +it("parses validation-only mode", () => { + assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); +}); + +it("selects an explicit CI Android ABI without changing the local default", () => { + assert.equal(resolveShowcaseAndroidAbi(undefined), "arm64-v8a"); + assert.equal(resolveShowcaseAndroidAbi("x86_64"), "x86_64"); + assert.throws(() => resolveShowcaseAndroidAbi("mips"), /Unsupported T3_SHOWCASE_ANDROID_ABI/u); +}); + +it("uses platform-correct default Android SDK roots", () => { + assert.equal( + resolveAndroidSdkRoot({ HOME: "/Users/showcase" }, "darwin"), + "/Users/showcase/Library/Android/sdk", + ); + assert.equal( + resolveAndroidSdkRoot({ HOME: "/home/showcase" }, "linux"), + "/home/showcase/Android/Sdk", + ); + assert.equal( + resolveAndroidSdkRoot( + { HOME: "/home/showcase", ANDROID_SDK_ROOT: "/opt/android-sdk" }, + "linux", + ), + "/opt/android-sdk", + ); +}); + +it("plans only scenes supported by each selected device", () => { + const options = parseShowcaseCliArgs(["--platform", "all", "--scene", "terminal"]); + const captures = planShowcaseCaptures(config, options); + assert.deepStrictEqual( + captures.map((capture) => ({ + id: capture.device.id, + appearance: capture.appearance, + scenes: capture.scenes, + })), + [{ id: "pixel", appearance: "light", scenes: ["terminal"] }], + ); +}); + +it("expands both appearances into independent upload-ready directories", () => { + const options = parseShowcaseCliArgs(["--device", "phone", "--appearance", "both"]); + const captures = planShowcaseCaptures(config, options); + + assert.deepStrictEqual( + captures.map((capture) => ({ + appearance: capture.appearance, + directory: showcaseCaptureDirectory("/captures", capture), + })), + [ + { appearance: "light", directory: "/captures/apple/iphone-test/light" }, + { appearance: "dark", directory: "/captures/apple/iphone-test/dark" }, + ], + ); +}); + +it("rejects unknown devices instead of silently capturing another target", () => { + const options = parseShowcaseCliArgs(["--device", "missing"]); + assert.throws(() => planShowcaseCaptures(config, options), /Unknown device 'missing'/u); +}); + +it("reads captured PNG dimensions from the IHDR header", () => { + const bytes = new Uint8Array(26); + bytes.set([137, 80, 78, 71, 13, 10, 26, 10]); + const view = new DataView(bytes.buffer); + view.setUint32(16, 1320); + view.setUint32(20, 2868); + view.setUint8(24, 8); + view.setUint8(25, 2); + assert.deepStrictEqual(readPngDimensions(bytes), { width: 1320, height: 2868 }); + assert.deepStrictEqual(readPngMetadata(bytes), { + width: 1320, + height: 2868, + bitDepth: 8, + colorType: 2, + hasAlpha: false, + }); +}); + +function rgbaPng(width: number, height: number): Buffer { + const png = new PNG({ width, height }); + png.data.fill(255); + return PNG.sync.write(png); +} + +it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => { + const normalized = normalizeStorePng(rgbaPng(2, 3)); + assert.deepStrictEqual(readPngMetadata(normalized), { + width: 2, + height: 3, + bitDepth: 8, + colorType: 2, + hasAlpha: false, + }); +}); + +it("validates exact Apple and Google Play upload assets", () => { + const apple = normalizeStorePng(rgbaPng(appleSpec.width, appleSpec.height)); + const google = normalizeStorePng(rgbaPng(googleSpec.width, googleSpec.height)); + assert.equal(validateStoreAsset(appleSpec, apple).width, 1284); + assert.equal(validateStoreAsset(googleSpec, google).height, 1920); +}); + +it("rejects wrong dimensions and alpha-bearing PNGs", () => { + const wrongSize = normalizeStorePng(rgbaPng(1242, 2688)); + assert.throws(() => validateStoreAsset(appleSpec, wrongSize), /requires 1284×2778/u); + assert.throws(() => validateStoreAsset(appleSpec, rgbaPng(1284, 2778)), /without alpha/u); +}); + +it("enforces store screenshot count limits", () => { + assert.doesNotThrow(() => validateStoreAssetCount(googleSpec, 5, true)); + assert.throws(() => validateStoreAssetCount(googleSpec, 1, true), /requires at least 2/u); + assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u); +}); + +it("configures every default device with an exact upload-ready store target", () => { + assert.deepStrictEqual( + showcaseConfig.devices.map((device) => [ + device.id, + device.storeAsset.directory, + device.storeAsset.width, + device.storeAsset.height, + ]), + [ + ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "apple/ipad-13", 2064, 2752], + ["pixel", "google-play/phone", 1080, 1920], + ["android-tablet-7", "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", "google-play/tablet-10", 1440, 2560], + ], + ); +}); + +it("selects a reachable LAN IPv4 address", () => { + assert.equal( + selectLanIpv4Address([ + { address: "127.0.0.1", family: "IPv4", internal: true }, + { address: "fe80::1", family: "IPv6", internal: false }, + { address: "169.254.2.4", family: "IPv4", internal: false }, + { address: "192.168.1.80", family: "IPv4", internal: false }, + ]), + "192.168.1.80", + ); +}); + +it("maps capture scenes to the real application routes", () => { + assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); + assert.equal( + showcaseSceneUrl("environments", "environment-1"), + "t3code-dev://settings/environments", + ); + assert.equal( + showcaseSceneUrl("thread", "environment-1"), + "t3code-dev://threads/environment-1/remote-command-center", + ); + assert.equal( + showcaseSceneUrl("terminal", "environment-1"), + "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", + ); + assert.equal( + showcaseSceneUrl("review", "environment-1"), + "t3code-dev://threads/environment-1/remote-command-center/review", + ); +}); + +it("seeds a playful multi-environment project spectrum", () => { + assert.deepStrictEqual( + SHOWCASE_PROJECTS.map((project) => project.title), + ["T3 Code", "React", "Linux"], + ); + assert.deepStrictEqual( + SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), + ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], + ); + assert.equal(SHOWCASE_THREADS.length, 6); + assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3); + assert.equal( + SHOWCASE_PROJECTS.every((project) => project.favicon.includes(" { + assert.equal( + parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'), + "PAIR-ME", + ); +}); + +it("encodes Android pairing URLs without shell-sensitive JSON quotes", () => { + const urls = ["http://10.0.2.2:65164/#token=ONE", "http://10.0.2.2:65198/#token=TWO"]; + const encoded = encodeAndroidPairingUrls(urls); + assert.equal(encoded.startsWith("json-uri:"), true); + assert.deepStrictEqual(JSON.parse(decodeURIComponent(encoded.slice("json-uri:".length))), urls); + assert.equal(encoded.includes('"'), false); +}); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts new file mode 100644 index 0000000000000..6636b380abcbd --- /dev/null +++ b/scripts/mobile-showcase.ts @@ -0,0 +1,1344 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly. + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import * as NodeURL from "node:url"; + +import { PNG } from "pngjs"; + +import showcaseConfig, { + type ShowcaseAppearance, + type ShowcaseAndroidDevice, + type ShowcaseConfig, + type ShowcaseDevice, + type ShowcaseIosDevice, + type ShowcaseStoreAssetSpec, + SHOWCASE_SCENES, + type ShowcaseScene, +} from "./mobile-showcase.config.ts"; +import { + SHOWCASE_ENVIRONMENTS, + SHOWCASE_PROJECTS, + SHOWCASE_TERMINAL_ID, + SHOWCASE_THREAD_ID, + seedShowcaseEnvironment, +} from "./mobile-showcase-environment.ts"; + +const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); +const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; +const APP_SCHEME = "t3code-dev"; +const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; +const SERVER_HOST = "0.0.0.0"; +const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; +const IOS_APP_PATH = NodePath.join( + MOBILE_ROOT, + ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", +); +const ANDROID_APK_PATH = NodePath.join( + MOBILE_ROOT, + "android/app/build/outputs/apk/debug/app-debug.apk", +); +export function resolveAndroidSdkRoot( + environment: Readonly>, + platform: NodeJS.Platform = NodeProcess.platform, +): string { + const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT; + if (configured) return configured; + const home = environment.HOME ?? environment.USERPROFILE ?? ""; + return NodePath.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk"); +} + +const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); +const MOBILE_BUILD_ENV = { + ...NodeProcess.env, + ANDROID_HOME: ANDROID_SDK_ROOT, + APP_VARIANT: "development", + EXPO_NO_GIT_STATUS: "1", + JAVA_HOME: + NodeProcess.env.JAVA_HOME ?? + (NodeProcess.platform === "darwin" + ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home" + : undefined), + NODE_ENV: "development", +}; + +interface CliOptions { + readonly platforms: ReadonlySet; + readonly deviceIds: ReadonlySet; + readonly scenes: ReadonlySet; + readonly appearances: ReadonlySet; + readonly skipBuild: boolean; + readonly skipMetro: boolean; + readonly keepRunning: boolean; + readonly validateOnly: boolean; + readonly list: boolean; +} + +export interface ShowcaseCapture { + readonly device: ShowcaseDevice; + readonly scenes: ReadonlyArray; + readonly appearance: ShowcaseAppearance; +} + +interface IosCaptureCleanup { + readonly udid: string; + readonly startedByRunner: boolean; + readonly createdByRunner: boolean; +} + +interface AndroidCaptureCleanup { + readonly device: ShowcaseAndroidDevice; + readonly serial: string; + readonly startedByRunner: boolean; +} + +interface NetworkAddress { + readonly address: string; + readonly family: string; + readonly internal: boolean; +} + +export function selectLanIpv4Address(addresses: ReadonlyArray): string | null { + return ( + addresses.find( + ({ address, family, internal }) => + family === "IPv4" && !internal && !address.startsWith("169.254."), + )?.address ?? null + ); +} + +function lanIpv4Address(): string { + const address = selectLanIpv4Address( + Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []), + ); + if (!address) { + throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro."); + } + return address; +} + +export interface PngMetadata { + readonly width: number; + readonly height: number; + readonly bitDepth: number; + readonly colorType: number; + readonly hasAlpha: boolean; +} + +export function readPngMetadata(bytes: Uint8Array): PngMetadata { + const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; + if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) { + throw new Error("Captured file is not a valid PNG."); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const colorType = view.getUint8(25); + return { + width: view.getUint32(16), + height: view.getUint32(20), + bitDepth: view.getUint8(24), + colorType, + hasAlpha: colorType === 4 || colorType === 6, + }; +} + +export function readPngDimensions(bytes: Uint8Array): { + readonly width: number; + readonly height: number; +} { + const { width, height } = readPngMetadata(bytes); + return { width, height }; +} + +export function normalizeStorePng(bytes: Uint8Array): Buffer { + const png = PNG.sync.read(Buffer.from(bytes)); + return PNG.sync.write(png, { + bitDepth: 8, + colorType: 2, + inputColorType: 6, + inputHasAlpha: true, + }); +} + +export function validateStoreAsset( + spec: ShowcaseStoreAssetSpec, + bytes: Uint8Array, + label = "Screenshot", +): PngMetadata { + const metadata = readPngMetadata(bytes); + if (metadata.width !== spec.width || metadata.height !== spec.height) { + throw new Error( + `${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`, + ); + } + if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) { + throw new Error( + `${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`, + ); + } + if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) { + throw new Error( + `${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`, + ); + } + if (spec.store === "google-play") { + const shortestSide = Math.min(metadata.width, metadata.height); + const longestSide = Math.max(metadata.width, metadata.height); + if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) { + throw new Error( + `${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`, + ); + } + if (metadata.width * 16 !== metadata.height * 9) { + throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`); + } + } + return metadata; +} + +export function validateStoreAssetCount( + spec: ShowcaseStoreAssetSpec, + count: number, + requireMinimum: boolean, +): void { + if (count > spec.maximumUploadCount) { + throw new Error( + `${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`, + ); + } + if (requireMinimum && count < spec.minimumUploadCount) { + throw new Error( + `${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`, + ); + } +} + +export function showcaseCaptureDirectory( + outputDirectory: string, + capture: Pick, +): string { + return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance); +} + +async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise { + const normalized = normalizeStorePng(await NodeFSP.readFile(destination)); + await NodeFSP.writeFile(destination, normalized); + const metadata = validateStoreAsset( + device.storeAsset, + normalized, + NodePath.basename(destination), + ); + NodeProcess.stdout.write( + `Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`, + ); +} + +async function validateCaptureSet( + capture: ShowcaseCapture, + outputDirectory: string, + requireMinimum: boolean, +): Promise { + const directory = showcaseCaptureDirectory(outputDirectory, capture); + const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort(); + const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort(); + const missingFiles = expectedFiles.filter((file) => !files.includes(file)); + if (missingFiles.length > 0) { + throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`); + } + validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum); + for (const file of files) { + const bytes = await NodeFSP.readFile(NodePath.join(directory, file)); + validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`); + } + NodeProcess.stdout.write( + `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`, + ); +} + +function argumentValue(args: ReadonlyArray, index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + +export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { + const platforms = new Set(); + const deviceIds = new Set(); + const scenes = new Set(); + const appearances = new Set(); + let skipBuild = false; + let skipMetro = false; + let keepRunning = false; + let validateOnly = false; + let list = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--platform") { + const value = argumentValue(args, index, argument); + if (value !== "ios" && value !== "android" && value !== "all") { + throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`); + } + if (value === "all") { + platforms.add("ios"); + platforms.add("android"); + } else { + platforms.add(value); + } + index += 1; + } else if (argument === "--device") { + deviceIds.add(argumentValue(args, index, argument)); + index += 1; + } else if (argument === "--scene") { + const value = argumentValue(args, index, argument); + if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) { + throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`); + } + scenes.add(value as ShowcaseScene); + index += 1; + } else if (argument === "--appearance") { + const value = argumentValue(args, index, argument); + if (value !== "light" && value !== "dark" && value !== "both") { + throw new Error(`Unsupported appearance '${value}'. Use light, dark, or both.`); + } + if (value === "both") { + appearances.add("light"); + appearances.add("dark"); + } else { + appearances.add(value); + } + index += 1; + } else if (argument === "--skip-build") { + skipBuild = true; + } else if (argument === "--skip-metro") { + skipMetro = true; + } else if (argument === "--keep-running") { + keepRunning = true; + } else if (argument === "--validate-only") { + validateOnly = true; + } else if (argument === "--list") { + list = true; + } else if (argument === "--help" || argument === "-h") { + list = true; + } else { + throw new Error(`Unknown option '${argument}'.`); + } + } + + return { + platforms, + deviceIds, + scenes, + appearances, + skipBuild, + skipMetro, + keepRunning, + validateOnly, + list, + }; +} + +export function planShowcaseCaptures( + config: ShowcaseConfig, + options: Pick, +): ReadonlyArray { + const captures = config.devices + .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform)) + .filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id)) + .flatMap((device) => { + const appearances = + options.appearances.size === 0 ? [device.appearance] : options.appearances; + return [...appearances].map((appearance) => ({ + device, + appearance, + scenes: + options.scenes.size === 0 + ? device.scenes + : device.scenes.filter((scene) => options.scenes.has(scene)), + })); + }) + .filter((capture) => capture.scenes.length > 0); + + const knownDeviceIds = new Set(config.devices.map((device) => device.id)); + for (const id of options.deviceIds) { + if (!knownDeviceIds.has(id)) { + throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`); + } + } + if (captures.length === 0) { + throw new Error("No captures match the selected platform, device, and scene filters."); + } + return captures; +} + +function printUsage(config: ShowcaseConfig): void { + NodeProcess.stdout.write(`App screenshot showcase + +Usage: + pnpm --filter @t3tools/mobile screenshots [options] + +Options: + --platform ios|android|all Capture one platform (repeatable) + --device Capture one configured device (repeatable) + --scene Capture one scene (repeatable) + --appearance light|dark|both + Override the configured appearance + --skip-build Reuse the existing simulator app / debug APK + --skip-metro Reuse an already running showcase Metro server + --keep-running Leave devices and Metro running after capture + --validate-only Validate existing upload assets without capturing + --list Print this help and the configured matrix + +Scenes: ${SHOWCASE_SCENES.join(", ")} + +Configured devices: +${config.devices + .map((device) => { + const target = device.platform === "ios" ? device.simulator : device.avd; + return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark} (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance}) [${device.scenes.join(", ")}]`; + }) + .join("\n")} +`); +} + +function spawnProcess( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.SpawnOptions = {}, +): NodeChildProcess.ChildProcess { + return NodeChildProcess.spawn(command, args, { + cwd: REPO_ROOT, + env: NodeProcess.env, + stdio: "inherit", + ...options, + }); +} + +async function runCommand( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.SpawnOptions = {}, +): Promise { + await new Promise((resolve, reject) => { + const child = spawnProcess(command, args, options); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`, + ), + ); + } + }); + }); +} + +async function commandOutput( + command: string, + args: ReadonlyArray, + options: NodeChildProcess.ExecFileOptions = {}, +): Promise { + return await new Promise((resolve, reject) => { + NodeChildProcess.execFile( + command, + [...args], + { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options }, + (error, stdout) => { + if (error) reject(error); + else resolve(String(stdout)); + }, + ); + }); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function stopProcess(child: NodeChildProcess.ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + child.kill("SIGTERM"); + await Promise.race([exited, delay(5_000)]); + if (child.exitCode !== null || child.signalCode !== null) return; + + child.kill("SIGKILL"); + await Promise.race([exited, delay(1_000)]); +} + +async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const open = await new Promise((resolve) => { + const socket = NodeNet.createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); + if (open) return; + await delay(500); + } + throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`); +} + +async function reserveAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = NodeNet.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not reserve a local port for the showcase environment.")); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +async function createShowcaseShell(baseDir: string): Promise { + const shellPath = NodePath.join(baseDir, "showcase-shell"); + await NodeFSP.writeFile( + shellPath, + `#!/bin/sh +if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then + exec /bin/sh -c "$2" +fi +exec /bin/cat +`, + { mode: 0o755 }, + ); + return shellPath; +} + +async function createShowcaseLabelProbe(baseDir: string, label: string): Promise { + const binDirectory = NodePath.join(baseDir, "showcase-bin"); + await NodeFSP.mkdir(binDirectory, { recursive: true }); + const probeScript = `#!/bin/sh +if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then + printf '%s\\n' ${JSON.stringify(label)} + exit 0 +fi +if [ "$1" = "--pretty" ]; then + printf '%s\\n' ${JSON.stringify(label)} + exit 0 +fi +exit 1 +`; + await Promise.all( + ["scutil", "hostnamectl"].map((executable) => + NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }), + ), + ); + return binDirectory; +} + +function startShowcaseServer( + baseDir: string, + workspaceRoot: string, + port: number, + shellPath: string, + labelProbeDirectory: string, +): NodeChildProcess.ChildProcess { + return spawnProcess( + "node", + [ + "apps/server/src/bin.ts", + "serve", + "--host", + SERVER_HOST, + "--port", + String(port), + "--base-dir", + baseDir, + "--no-browser", + "--log-level", + "error", + workspaceRoot, + ], + { + env: { + ...NodeProcess.env, + PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`, + SHELL: shellPath, + }, + }, + ); +} + +export function parsePairingCredentialOutput(output: string): string { + const jsonStart = output.indexOf("{"); + const jsonEnd = output.lastIndexOf("}"); + if (jsonStart === -1 || jsonEnd < jsonStart) { + throw new Error("Pairing credential command did not return JSON."); + } + const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as { + readonly credential?: unknown; + }; + if (typeof parsed.credential !== "string" || parsed.credential.length === 0) { + throw new Error("Pairing credential command returned no credential."); + } + return parsed.credential; +} + +async function issuePairingCredential(baseDir: string): Promise { + const output = await commandOutput( + "node", + ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"], + { env: { ...NodeProcess.env, NO_COLOR: "1" } }, + ); + return parsePairingCredentialOutput(output); +} + +function buildShowcasePairingUrl(host: string, port: number, credential: string): string { + const url = new URL(`http://${host}:${port}/`); + url.hash = new URLSearchParams([["token", credential]]).toString(); + return url.toString(); +} + +export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): string { + if (scene === "threads") return `${APP_SCHEME}://`; + if (scene === "environments") return `${APP_SCHEME}://settings/environments`; + const threadPath = `threads/${encodeURIComponent(environmentId)}/${SHOWCASE_THREAD_ID}`; + if (scene === "thread") return `${APP_SCHEME}://${threadPath}`; + if (scene === "terminal") { + return `${APP_SCHEME}://${threadPath}/terminal?terminalId=${SHOWCASE_TERMINAL_ID}`; + } + return `${APP_SCHEME}://${threadPath}/review`; +} + +export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray): string { + return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`; +} + +function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess { + return spawnProcess( + "pnpm", + ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)], + { + cwd: MOBILE_ROOT, + env: { + ...MOBILE_BUILD_ENV, + EXPO_PUBLIC_SHOWCASE: "1", + }, + }, + ); +} + +async function warmMetroBundle( + platform: ShowcaseDevice["platform"], + host: string, + config: ShowcaseConfig, +): Promise { + const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`; + await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]); +} + +async function buildIos(): Promise { + const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data"); + await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], { + cwd: MOBILE_ROOT, + env: MOBILE_BUILD_ENV, + }); + await runCommand( + "xcodebuild", + [ + "-workspace", + NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"), + "-scheme", + "T3CodeDev", + "-configuration", + "Debug", + "-sdk", + "iphonesimulator", + "-derivedDataPath", + derivedData, + "-quiet", + `ARCHS=${IOS_SIMULATOR_ARCH}`, + "ONLY_ACTIVE_ARCH=YES", + "build", + ], + { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV }, + ); + return IOS_APP_PATH; +} + +async function buildAndroid(abis: ReadonlyArray): Promise { + await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], { + cwd: MOBILE_ROOT, + env: MOBILE_BUILD_ENV, + }); + await runCommand( + "./gradlew", + [ + "app:assembleDebug", + ...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []), + ], + { + cwd: NodePath.join(MOBILE_ROOT, "android"), + env: MOBILE_BUILD_ENV, + }, + ); + return ANDROID_APK_PATH; +} + +async function existingArtifact(path: string): Promise { + return await NodeFSP.access(path).then( + () => path, + () => null, + ); +} + +interface SimctlDevice { + readonly name: string; + readonly udid: string; + readonly state: "Booted" | "Shutdown" | string; + readonly isAvailable: boolean; +} + +async function findIosSimulator(name: string): Promise { + const parsed = JSON.parse( + await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]), + ) as { + readonly devices: Readonly>>; + }; + const candidates = Object.entries(parsed.devices) + .filter(([runtime]) => runtime.includes("iOS")) + .flatMap(([, devices]) => devices) + .filter((device) => device.isAvailable && device.name === name); + return candidates.at(-1) ?? null; +} + +async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{ + readonly simulator: SimctlDevice; + readonly createdByRunner: boolean; +}> { + const existing = await findIosSimulator(device.simulator); + if (existing) return { simulator: existing, createdByRunner: false }; + if (!device.simulatorDeviceType) { + throw new Error( + `iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`, + ); + } + const udid = ( + await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType]) + ).trim(); + if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`); + return { + simulator: { + name: device.simulator, + udid, + state: "Shutdown", + isAvailable: true, + }, + createdByRunner: true, + }; +} + +async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise { + await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]); + await runCommand("xcrun", [ + "simctl", + "status_bar", + udid, + "override", + "--time", + "9:41", + "--batteryState", + "charged", + "--batteryLevel", + "100", + "--wifiBars", + "3", + "--cellularBars", + "4", + ]); +} + +async function iosAppContainer(udid: string): Promise { + return ( + await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) + ).trim(); +} + +async function waitForIosShowcaseScene( + udid: string, + scene: ShowcaseScene, + timeoutMs = 90_000, +): Promise { + const readyPath = NodePath.join( + await iosAppContainer(udid), + "Library/Caches", + IOS_READY_FILENAME, + ); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => ""); + if (readyScene.trim() === scene) return; + await delay(500); + } + throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`); +} + +async function captureIos( + capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice }, + appPath: string | null, + outputDirectory: string, + config: ShowcaseConfig, + metroHost: string, + pairingUrls: ReadonlyArray, + registerCleanup: (cleanup: IosCaptureCleanup) => void, +): Promise { + const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); + const startedByRunner = simulator.state !== "Booted"; + registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); + if (!startedByRunner) { + // Clear transient SpringBoard state (permission prompts, stale URL-open + // confirmations, keyboards) without erasing the developer's simulator. + await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]); + } + await runCommand("xcrun", ["simctl", "boot", simulator.udid]); + await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); + await normalizeIosSimulator(capture.appearance, simulator.udid); + if (appPath) { + await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch( + () => undefined, + ); + await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]); + } + + for (const [key, value] of [ + ["EXDevMenuIsOnboardingFinished", "true"], + ["EXDevMenuShowFloatingActionButton", "false"], + ["EXDevMenuShowsAtLaunch", "false"], + ] as const) { + await runCommand("xcrun", [ + "simctl", + "spawn", + simulator.udid, + "defaults", + "write", + ANDROID_PACKAGE, + key, + "-bool", + value, + ]); + } + + const metroUrl = `http://${metroHost}:${config.metroPort}?disableOnboarding=1`; + const scenePath = NodePath.join( + await iosAppContainer(simulator.udid), + "Library/Caches/T3ShowcaseScene", + ); + const readyPath = NodePath.join( + await iosAppContainer(simulator.udid), + "Library/Caches", + IOS_READY_FILENAME, + ); + const firstScene = capture.scenes[0] ?? "threads"; + const launchShowcaseApp = async (terminateRunningProcess: boolean) => { + await runCommand("xcrun", [ + "simctl", + "launch", + ...(terminateRunningProcess ? ["--terminate-running-process"] : []), + simulator.udid, + ANDROID_PACKAGE, + "--initialUrl", + metroUrl, + "--showcasePairingUrl", + JSON.stringify(pairingUrls), + "--showcaseScene", + firstScene, + ]); + }; + await NodeFSP.rm(readyPath, { force: true }); + await NodeFSP.writeFile(scenePath, firstScene); + await launchShowcaseApp(false); + for (const [sceneIndex, scene] of capture.scenes.entries()) { + if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true }); + await NodeFSP.writeFile(scenePath, scene); + if (sceneIndex === 0) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const isLastAttempt = attempt === 1; + try { + // A freshly installed Expo development build can spend well over 30s + // applying an already-bundled update after it reaches 100%. Killing it + // at that point sends the next capture back to the dev launcher. + await waitForIosShowcaseScene(simulator.udid, scene, 120_000); + break; + } catch (error) { + if (isLastAttempt) throw error; + await launchShowcaseApp(true); + } + } + } else { + await waitForIosShowcaseScene(simulator.udid, scene); + } + await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); + const destination = NodePath.join( + showcaseCaptureDirectory(outputDirectory, capture), + `${scene}.png`, + ); + await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + await finalizeCapture(destination, capture.device); + } +} + +function androidSdkTool(relativePath: string): string { + return NodePath.join(ANDROID_SDK_ROOT, relativePath); +} + +async function adbOutput(serial: string, args: ReadonlyArray): Promise { + return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); +} + +async function runAdb(serial: string, args: ReadonlyArray): Promise { + await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); +} + +async function runningAndroidAvds(): Promise> { + const adb = androidSdkTool("platform-tools/adb"); + const devices = (await commandOutput(adb, ["devices"])) + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device") + .map((parts) => parts[0] as string); + const result = new Map(); + for (const serial of devices) { + const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim(); + if (avdName) result.set(avdName, serial); + } + return result; +} + +async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const serial = (await runningAndroidAvds()).get(avd); + if (serial) { + await runAdb(serial, ["wait-for-device"]); + const bootCompleted = ( + await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"]) + ).trim(); + if (bootCompleted === "1") return serial; + } + await delay(1_000); + } + throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`); +} + +async function normalizeAndroidEmulator( + device: ShowcaseAndroidDevice, + appearance: ShowcaseAppearance, + serial: string, +): Promise { + await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]); + await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]); + await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]); + await runAdb(serial, ["shell", "cmd", "uimode", "night", appearance === "dark" ? "yes" : "no"]); + await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]); + await runAdb(serial, ["emu", "power", "capacity", "100"]); + await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]); + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "enter", + ]); + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "clock", + "-e", + "hhmm", + "0941", + ]); + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "battery", + "-e", + "level", + "100", + "-e", + "plugged", + "false", + ]); + if (device.viewport) { + await runAdb(serial, [ + "shell", + "wm", + "size", + `${device.viewport.width}x${device.viewport.height}`, + ]); + if (device.viewport.density) { + await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]); + } + } +} + +async function waitForAndroidShowcaseScene( + serial: string, + scene: ShowcaseScene, + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const readyScene = await adbOutput(serial, [ + "shell", + "run-as", + ANDROID_PACKAGE, + "cat", + "files/t3-showcase-ready", + ]).catch(() => ""); + if (readyScene.trim() === scene) return; + await delay(500); + } + throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`); +} + +async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise { + await runAdb(serial, [ + "shell", + `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`, + ]); +} + +async function prepareAndroidShowcaseApp(serial: string): Promise { + const preferences = ` + + + + + + + +`; + const encodedPreferences = Buffer.from(preferences).toString("base64"); + await runAdb(serial, [ + "shell", + `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`, + ]); +} + +async function captureAndroid( + capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, + apkPath: string | null, + outputDirectory: string, + config: ShowcaseConfig, + pairingUrls: ReadonlyArray, + registerCleanup: (cleanup: AndroidCaptureCleanup) => void, +): Promise { + const running = await runningAndroidAvds(); + const existingSerial = running.get(capture.device.avd); + const startedByRunner = !existingSerial; + let launchedEmulator: NodeChildProcess.ChildProcess | null = null; + if (startedByRunner) { + const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"])) + .split("\n") + .map((value) => value.trim()); + if (!installedAvds.includes(capture.device.avd)) { + throw new Error( + `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`, + ); + } + launchedEmulator = spawnProcess( + androidSdkTool("emulator/emulator"), + ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"], + { stdio: "ignore", detached: true }, + ); + launchedEmulator.unref(); + } + const serial = + existingSerial ?? + (await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => { + if (launchedEmulator) await stopProcess(launchedEmulator); + throw error; + })); + registerCleanup({ device: capture.device, serial, startedByRunner }); + await normalizeAndroidEmulator(capture.device, capture.appearance, serial); + if (apkPath) { + await runAdb(serial, ["install", "-r", apkPath]); + } + await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]); + await prepareAndroidShowcaseApp(serial); + await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]); + const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`); + const firstScene = capture.scenes[0] ?? "threads"; + await runAdb(serial, [ + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + `${APP_SCHEME}://expo-development-client/?url=${metroUrl}`, + "--es", + "showcasePairingUrl", + encodeAndroidPairingUrls(pairingUrls), + "--es", + "showcaseScene", + firstScene, + ANDROID_PACKAGE, + ]); + for (const [sceneIndex, scene] of capture.scenes.entries()) { + if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene); + await waitForAndroidShowcaseScene(serial, scene); + await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); + const destination = NodePath.join( + showcaseCaptureDirectory(outputDirectory, capture), + `${scene}.png`, + ); + const png = await new Promise((resolve, reject) => { + NodeChildProcess.execFile( + androidSdkTool("platform-tools/adb"), + ["-s", serial, "exec-out", "screencap", "-p"], + { cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }, + ); + }); + await NodeFSP.writeFile(destination, png); + await finalizeCapture(destination, capture.device); + } +} + +async function cleanupAndroidViewport( + device: ShowcaseAndroidDevice, + serial: string, +): Promise { + await runAdb(serial, [ + "shell", + "am", + "broadcast", + "-a", + "com.android.systemui.demo", + "-e", + "command", + "exit", + ]); + if (!device.viewport) return; + await runAdb(serial, ["shell", "wm", "size", "reset"]); + if (device.viewport.density) { + await runAdb(serial, ["shell", "wm", "density", "reset"]); + } +} + +async function main(): Promise { + const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2)); + if (options.list) { + printUsage(showcaseConfig); + return; + } + const captures = planShowcaseCaptures(showcaseConfig, options); + const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); + if (options.validateOnly) { + for (const capture of captures) { + await validateCaptureSet( + capture, + outputDirectory, + capture.scenes.length === capture.device.scenes.length, + ); + } + return; + } + const hasIos = captures.some((capture) => capture.device.platform === "ios"); + const hasAndroid = captures.some((capture) => capture.device.platform === "android"); + const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1"; + await NodeFSP.mkdir(outputDirectory, { recursive: true }); + for (const capture of captures) { + const directory = showcaseCaptureDirectory(outputDirectory, capture); + await NodeFSP.rm(directory, { recursive: true, force: true }); + await NodeFSP.mkdir(directory, { recursive: true }); + } + + const showcaseRootDir = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), + ); + const showcaseServers: NodeChildProcess.ChildProcess[] = []; + const showcaseEnvironments: Array<{ + readonly baseDir: string; + readonly environmentId: string; + readonly label: string; + readonly port: number; + }> = []; + let metro: NodeChildProcess.ChildProcess | null = null; + const iosCleanups: IosCaptureCleanup[] = []; + const androidCleanups: AndroidCaptureCleanup[] = []; + + try { + for (const environment of SHOWCASE_ENVIRONMENTS) { + const projectId = environment.projectIds[0]; + const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); + if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); + + const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id); + const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); + const port = await reserveAvailablePort(); + await NodeFSP.mkdir(workspaceRoot, { recursive: true }); + const shellPath = await createShowcaseShell(baseDir); + const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); + const server = startShowcaseServer( + baseDir, + workspaceRoot, + port, + shellPath, + labelProbeDirectory, + ); + showcaseServers.push(server); + await waitForPort(port, `${environment.label} server`); + await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); + const environmentId = ( + await NodeFSP.readFile(NodePath.join(baseDir, "userdata", "environment-id"), "utf8") + ).trim(); + if (!environmentId) { + throw new Error(`${environment.label} did not persist an environment id.`); + } + showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); + } + + if (!options.skipMetro) { + metro = startMetro(showcaseConfig); + await waitForPort(showcaseConfig.metroPort, "Metro"); + await Promise.all([ + hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(), + hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(), + ]); + } + + const iosAppPath = hasIos + ? options.skipBuild + ? await existingArtifact(IOS_APP_PATH) + : await buildIos() + : null; + const androidAbis = captures.flatMap((capture) => + capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [], + ); + const androidApkPath = hasAndroid + ? options.skipBuild + ? await existingArtifact(ANDROID_APK_PATH) + : await buildAndroid([...new Set(androidAbis)]) + : null; + + for (const capture of captures) { + const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2"; + const pairingUrls = await Promise.all( + showcaseEnvironments.map(async (environment) => { + const credential = await issuePairingCredential(environment.baseDir); + return buildShowcasePairingUrl(pairingHost, environment.port, credential); + }), + ); + if (capture.device.platform === "ios") { + await captureIos( + capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice }, + iosAppPath, + outputDirectory, + showcaseConfig, + metroHost, + pairingUrls, + (cleanup) => iosCleanups.push(cleanup), + ); + } else { + await captureAndroid( + capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, + androidApkPath, + outputDirectory, + showcaseConfig, + pairingUrls, + (cleanup) => androidCleanups.push(cleanup), + ); + } + await validateCaptureSet( + capture, + outputDirectory, + capture.scenes.length === capture.device.scenes.length, + ); + } + + NodeProcess.stdout.write( + `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`, + ); + if (options.keepRunning) { + const serverSummary = showcaseEnvironments + .map((environment) => `${environment.label}:${environment.port}`) + .join(", "); + NodeProcess.stdout.write( + `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`, + ); + } + } finally { + if (options.keepRunning) { + metro?.unref(); + for (const server of showcaseServers) server.unref(); + } else { + for (const cleanup of androidCleanups) { + await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined); + if (cleanup.startedByRunner) { + await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined); + } + } + for (const cleanup of iosCleanups) { + if (cleanup.startedByRunner || cleanup.createdByRunner) { + await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); + } + if (cleanup.createdByRunner) { + await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined); + } + } + await Promise.all([ + ...(metro ? [stopProcess(metro)] : []), + ...showcaseServers.map((server) => stopProcess(server)), + ]); + await NodeFSP.rm(showcaseRootDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + } + } +} + +if (import.meta.main) { + void main().catch((error: unknown) => { + NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + NodeProcess.exit(1); + }); +} diff --git a/scripts/package.json b/scripts/package.json index 510bb230b8c97..629163d688e93 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -11,10 +11,12 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "effect": "catalog:", + "pngjs": "7.0.0", "yaml": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/pngjs": "6.0.5", "vite-plus": "catalog:" } }