From 3535a35edc19817204329e0770feda52e02a7b23 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 9 Oct 2026 10:02:03 +0200 Subject: [PATCH] feat(desktop): build Chromeria V2 as a side-by-side app identity Chromeria V2 has to install and run beside the daily Chromeria app without sharing its bundle, Electron profile, URL scheme or T3 home, and without importing or registering anything that belongs to v1. CHROMERIA_DESKTOP_VARIANT=v2 now selects one fork-owned identity (scripts/lib/chromeria-desktop-identity.ts) at build time: Chromeria V2, md.toolbox.chromeria.v2, profile chromeria-v2, only the chromeria-v2:// scheme, and default home ~/.t3-v2 with an explicit T3CODE_HOME still winning. The default build is unchanged. - Packaging uses the identity's app id, product name, artifact name and macOS URL scheme, records chromeriaDesktopVariant in the packaged package.json, and rejects V2 on other platforms and unknown variants. - The desktop pack writes dist-electron/chromeria-desktop-identity.json binding the variant to the SHA-256 of main.cjs after it is written; the artifact script refuses a missing, mismatched or stale marker, so --skip-build cannot package another identity's bundle. - At startup V2 names itself, sets its own profile and takes a profile-scoped single-instance lock on macOS before the Clerk bridge registers its scheme; a second V2 instance quits there. V2 skips the V1 profile and Local Storage imports. - The shared ChatGPT handoff helpers take an optional explicit scheme and the provider return allowlist accepts chromeria-v2://app, so V2 receives only chromeria-v2://auth/codex handoffs and returns to its own origin. Refs #177 Model/harness: Claude Opus 5.5 through Claude Code in T3 Code. Co-Authored-By: Claude Opus 5.5 --- .../src/app/ChromeriaDesktopV2.test.ts | 339 ++++++++++++++++++ apps/desktop/src/app/DesktopClerk.ts | 25 +- apps/desktop/src/app/DesktopEnvironment.ts | 9 +- .../src/app/DesktopLegacyLocalStorage.ts | 6 +- apps/desktop/src/app/DesktopStatePaths.ts | 4 +- apps/desktop/src/app/DesktopUserData.ts | 5 + apps/desktop/src/electron/ElectronProtocol.ts | 25 +- apps/desktop/vite.config.ts | 6 + docs/fork-features.md | 24 +- docs/fork.md | 20 ++ .../src/codexAuthHandoff.chromeria.test.ts | 87 +++++ packages/shared/src/codexAuthHandoff.ts | 17 +- packages/shared/src/providerAuthReturnUrl.ts | 4 +- scripts/build-desktop-artifact.test.ts | 74 ++++ scripts/build-desktop-artifact.ts | 80 ++++- scripts/fork-upstream-edits.txt | 7 + scripts/lib/chromeria-desktop-identity.ts | 58 +++ scripts/lib/desktop-identity-marker.test.ts | 80 +++++ scripts/lib/desktop-identity-marker.ts | 60 ++++ 19 files changed, 898 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/app/ChromeriaDesktopV2.test.ts create mode 100644 packages/shared/src/codexAuthHandoff.chromeria.test.ts create mode 100644 scripts/lib/chromeria-desktop-identity.ts create mode 100644 scripts/lib/desktop-identity-marker.test.ts create mode 100644 scripts/lib/desktop-identity-marker.ts diff --git a/apps/desktop/src/app/ChromeriaDesktopV2.test.ts b/apps/desktop/src/app/ChromeriaDesktopV2.test.ts new file mode 100644 index 0000000000..46b9577819 --- /dev/null +++ b/apps/desktop/src/app/ChromeriaDesktopV2.test.ts @@ -0,0 +1,339 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - The handoff test uses a real localhost listener without an OpenAI account. +import * as NodeHttp from "node:http"; +import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { beforeEach, vi } from "vite-plus/test"; + +// Every module under test reads the embedded identity; this file runs them as a V2 build. +vi.mock("../../../../scripts/lib/chromeria-desktop-identity.ts", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, CHROMERIA_DESKTOP_IDENTITY: actual.CHROMERIA_DESKTOP_IDENTITIES.v2 }; +}); + +const { createClerkBridgeMock, registerSchemesAsPrivilegedMock, requestSingleInstanceLockMock } = + vi.hoisted(() => ({ + createClerkBridgeMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + requestSingleInstanceLockMock: vi.fn(), + })); +vi.mock("@clerk/electron", () => ({ createClerkBridge: createClerkBridgeMock })); +vi.mock("@clerk/electron/storage", () => ({ storage: () => ({}) })); +vi.mock("electron", () => ({ + app: { requestSingleInstanceLock: requestSingleInstanceLockMock }, + protocol: { registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock }, +})); + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; +import * as DesktopUserData from "./DesktopUserData.ts"; + +const PROFILE = "/Users/alice/Library/Application Support/chromeria-v2"; + +const layerEnvironment = (env: Record = {}, appVersion = "0.0.45") => + DesktopEnvironment.layer({ + dirname: + "/Applications/Chromeria V2.app/Contents/Resources/app.asar/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion, + appPath: "/Applications/Chromeria V2.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/Chromeria V2.app/Contents/Resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest(env)), + ), + ); + +/** A filesystem that records every call, so tests can prove nothing was read or written. */ +const recordingFileSystem = (calls: string[]) => + FileSystem.layerNoop({ + exists: (path) => Effect.sync(() => (calls.push(`exists:${path}`), false)), + readDirectory: (path) => Effect.sync(() => (calls.push(`readDirectory:${path}`), [])), + readFileString: (path) => Effect.sync(() => (calls.push(`readFileString:${path}`), "")), + makeDirectory: (path) => Effect.sync(() => void calls.push(`makeDirectory:${path}`)), + writeFileString: (path) => Effect.sync(() => void calls.push(`writeFileString:${path}`)), + }); + +const layerClerk = ( + electronApp: Partial, + shell: Partial = {}, +) => + DesktopClerk.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodePath.layerPosix, + layerEnvironment(), + Layer.succeed(ElectronApp.ElectronApp, electronApp as ElectronApp.ElectronApp["Service"]), + Layer.succeed(ElectronShell.ElectronShell, shell as ElectronShell.ElectronShell["Service"]), + recordingFileSystem([]), + ), + ), + ); + +describe("Chromeria V2 desktop identity", () => { + beforeEach(() => { + createClerkBridgeMock.mockReset(); + registerSchemesAsPrivilegedMock.mockReset(); + requestSingleInstanceLockMock.mockReset().mockReturnValue(true); + }); + + it.effect("defaults to ~/.t3-v2 with a fixed name, and an explicit T3CODE_HOME wins", () => + Effect.gen(function* () { + const defaults = yield* DesktopEnvironment.DesktopEnvironment.pipe( + Effect.provide(layerEnvironment({}, "0.0.45-nightly.20261009.1")), + ); + assert.equal(defaults.baseDir, "/Users/alice/.t3-v2"); + assert.equal(defaults.stateDir, "/Users/alice/.t3-v2/userdata"); + assert.equal(defaults.displayName, "Chromeria V2"); + + const explicit = yield* DesktopEnvironment.DesktopEnvironment.pipe( + Effect.provide(layerEnvironment({ T3CODE_HOME: "/Volumes/work/t3" })), + ); + assert.equal(explicit.baseDir, "/Volumes/work/t3"); + }), + ); + + it.effect("registers only the chromeria-v2 scheme, in every mode", () => + Effect.gen(function* () { + yield* Layer.build(ElectronProtocol.layerSchemePrivileges); + + const registered = registerSchemesAsPrivilegedMock.mock.calls.flatMap(([schemes]) => + (schemes as ReadonlyArray<{ readonly scheme: string }>).map((entry) => entry.scheme), + ); + assert.deepStrictEqual(registered, ["chromeria-v2"]); + assert.equal(ElectronProtocol.getDesktopUrl(false), "chromeria-v2://app/"); + assert.equal(ElectronProtocol.getDesktopUrl(true), "chromeria-v2://app/"); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + { platform: "win32" as const, isDevelopment: false }, + { platform: "darwin" as const, isDevelopment: true }, + ])( + "uses its own profile without migrating another ($platform, development: $isDevelopment)", + ({ platform, isDevelopment }) => + Effect.gen(function* () { + const calls: string[] = []; + const userData = yield* DesktopUserData.resolveUserDataPath({ + appDataDirectory: "/app-data", + isDevelopment, + platform, + }).pipe(Effect.provide(Layer.mergeAll(NodePath.layerPosix, recordingFileSystem(calls)))); + + assert.equal(userData, "/app-data/chromeria-v2"); + assert.deepStrictEqual(calls, []); + }), + ); + + it.effect("never reads a V1 profile's Local Storage", () => + Effect.gen(function* () { + const calls: string[] = []; + yield* Effect.gen(function* () { + const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; + yield* legacy.load(PROFILE); + assert.isTrue(Option.isNone(yield* legacy.take)); + }).pipe( + Effect.provide( + DesktopLegacyLocalStorage.layer.pipe( + Layer.provide( + Layer.mergeAll(layerEnvironment(), NodePath.layerPosix, recordingFileSystem(calls)), + ), + ), + ), + ); + assert.deepStrictEqual(calls, []); + }), + ); + + it.effect("names itself and locks its profile before the Clerk bridge exists", () => + Effect.gen(function* () { + const events: string[] = []; + createClerkBridgeMock.mockImplementation((options) => { + events.push(`createClerkBridge:${options.renderer.scheme}://${options.renderer.host}`); + return { cleanup: vi.fn(), isPrimaryInstance: true }; + }); + requestSingleInstanceLockMock.mockImplementation(() => (events.push("lock"), true)); + const electronApp = { + setName: (name: string) => Effect.sync(() => void events.push(`setName:${name}`)), + setPath: (name: string, value: string) => + Effect.sync(() => void events.push(`setPath:${name}:${value}`)), + } as unknown as ElectronApp.ElectronApp["Service"]; + + yield* Layer.build(layerClerk(electronApp)); + + assert.deepStrictEqual(events, [ + "setName:Chromeria V2", + `setPath:userData:${PROFILE}`, + "lock", + "createClerkBridge:chromeria-v2://app", + ]); + }).pipe(Effect.scoped), + ); + + it.effect("a second V2 instance quits before creating the bridge", () => + Effect.gen(function* () { + const quit = vi.fn(); + requestSingleInstanceLockMock.mockReturnValue(false); + const electronApp = { + setName: () => Effect.void, + setPath: () => Effect.void, + quit: Effect.sync(quit), + } as unknown as ElectronApp.ElectronApp["Service"]; + + const exit = yield* Effect.exit(Layer.build(layerClerk(electronApp))); + + assert.isTrue(Exit.hasInterrupts(exit)); + assert.equal(quit.mock.calls.length, 1); + assert.equal(createClerkBridgeMock.mock.calls.length, 0); + }).pipe(Effect.scoped), + ); + + it.effect("receives ChatGPT sign-in only through chromeria-v2://auth/codex", () => + Effect.gen(function* () { + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const port = yield* Effect.promise(async () => { + const server = NodeHttp.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("address"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; + }); + const authorize = new URL("https://auth.openai.com/api/accounts/authorize"); + authorize.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: `http://127.0.0.1:${port}/auth/callback`, + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + const request = { + authorizationUrl: authorize.toString(), + returnUrl: "https://app.t3.codes/welcome#agents:remote-one", + environmentId: EnvironmentId.make("remote-one"), + instanceId: ProviderInstanceId.make("work"), + flowId: "flow-one", + }; + const link = codexAuthHandoffUrl(request, false, "chromeria-v2"); + assert.isTrue(link.startsWith("chromeria-v2://auth/codex?request=")); + + const opened: string[] = []; + const delivered = Promise.withResolvers(); + const shell = { + openExternal: (value: unknown) => + Effect.promise(async () => { + opened.push(String(value)); + const url = new URL(String(value)); + const callback = new URL(url.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ + state: url.searchParams.get("state")!, + code: "test-code", + }).toString(); + const response = await fetch(callback, { redirect: "manual" }); + delivered.resolve(response.headers.get("location")!); + return true; + }), + }; + const listeners = new Map void>(); + const electronApp = { + setName: () => Effect.void, + setPath: () => Effect.void, + whenReady: Effect.void, + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => void listeners.set(name, listener)), + } as unknown as ElectronApp.ElectronApp["Service"]; + + yield* Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + const openUrl = (url: string) => { + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, url); + return event.preventDefault.mock.calls.length === 1; + }; + + // Handoffs addressed to the default app's schemes are not V2's to receive. + assert.isFalse(openUrl(codexAuthHandoffUrl(request))); + assert.isFalse(openUrl(codexAuthHandoffUrl(request, true))); + assert.deepStrictEqual(opened, []); + + assert.isTrue(openUrl(link)); + const delivery = readCodexAuthDelivery(yield* Effect.promise(() => delivered.promise)); + assert.equal(delivery?.flowId, request.flowId); + assert.equal(delivery?.returnUrl, request.returnUrl); + assert.deepStrictEqual(opened, [request.authorizationUrl]); + }).pipe( + Effect.provide(layerClerk(electronApp, shell)), + Effect.provideService(HostProcessArguments, ["Chromeria V2"]), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + ); + }).pipe(Effect.scoped), + ); + + it.effect("returns to provider setup only through chromeria-v2://app", () => + Effect.gen(function* () { + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const revealed = Promise.withResolvers(); + const loadURL = vi.fn(async (_url: string) => undefined); + const electronApp = { + setName: () => Effect.void, + setPath: () => Effect.void, + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => void listeners.set(name, listener)), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeedSome({ loadURL }), + reveal: () => Effect.sync(() => revealed.resolve()), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + + yield* Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, "t3code://app/settings/providers?instanceId=work"); + listeners.get("open-url")!(event, "chromeria-v2://attacker/settings/providers"); + assert.equal(event.preventDefault.mock.calls.length, 0); + + listeners.get("open-url")!( + event, + "chromeria-v2://app/settings/providers?instanceId=work&code=never-forward", + ); + assert.equal(event.preventDefault.mock.calls.length, 1); + yield* Effect.promise(() => revealed.promise); + assert.deepStrictEqual(loadURL.mock.calls, [ + ["chromeria-v2://app/settings/providers?instanceId=work"], + ]); + }).pipe( + Effect.provide(layerClerk(electronApp)), + Effect.provideService(HostProcessArguments, ["Chromeria V2"]), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 1bc390ac3c..d7d44e5325 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Electron from "electron"; import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff"; import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; @@ -16,6 +17,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { CHROMERIA_DESKTOP_IDENTITY } from "../../../../scripts/lib/chromeria-desktop-identity.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -94,11 +96,28 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const shell = yield* ElectronShell.ElectronShell; + // Chromeria (toolboxmd fork): V2 runs beside the default app, so it names + // itself before Electron derives anything from the app name. + const isV2 = CHROMERIA_DESKTOP_IDENTITY.variant === "v2"; + if (isV2) yield* electronApp.setName(CHROMERIA_DESKTOP_IDENTITY.productName); + // The SDK bridge acquires Electron's profile-scoped single-instance lock. // Must not yield: the bridge registers a scheme Electron rejects once ready. const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); + // Chromeria (toolboxmd fork): the SDK takes the lock only off macOS, so V2 + // takes its own profile lock there and a second V2 instance quits before the + // bridge registers its scheme. + if ( + isV2 && + environment.platform === "darwin" && + !(yield* Effect.sync(() => Electron.app.requestSingleInstanceLock())) + ) { + yield* electronApp.quit; + return yield* Effect.interrupt; + } + const bridge = yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), @@ -140,7 +159,11 @@ export const make = Effect.gen(function* () { const startProviderAuthHandoff = (value: string | undefined) => { if (!value) return false; - const request = readCodexAuthHandoff(value, environment.isDevelopment); + const request = readCodexAuthHandoff( + value, + environment.isDevelopment, + ElectronProtocol.getDesktopScheme(environment.isDevelopment), + ); if (!request) return false; void runPromise( Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index a86c055ec7..d651e67c5b 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { CHROMERIA_DESKTOP_IDENTITY } from "../../../../scripts/lib/chromeria-desktop-identity.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; @@ -95,7 +96,7 @@ export class DesktopEnvironment extends Context.Service< // Chromeria (toolboxmd fork): product name shown in window titles, menus and // the About panel. The stable build shows the bare name without "(Alpha)". -const APP_BASE_NAME = "Chromeria"; +const APP_BASE_NAME = CHROMERIA_DESKTOP_IDENTITY.productName; function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; @@ -116,7 +117,11 @@ export function resolveDesktopAppBranding(input: { return { baseName: APP_BASE_NAME, stageLabel, - displayName: stageLabel === "Alpha" ? APP_BASE_NAME : `${APP_BASE_NAME} (${stageLabel})`, + // V2's name is fixed so the running app matches its packaged product name. + displayName: + stageLabel === "Alpha" || CHROMERIA_DESKTOP_IDENTITY.variant !== "default" + ? APP_BASE_NAME + : `${APP_BASE_NAME} (${stageLabel})`, }; } diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index b29cfb3b7c..fbb72f8c71 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -7,6 +7,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { CHROMERIA_DESKTOP_IDENTITY } from "../../../../scripts/lib/chromeria-desktop-identity.ts"; import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -82,8 +83,9 @@ const make = Effect.gen(function* () { }); const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) { - // Development already shares its profile between versions. - if (environment.isDevelopment) return; + // Development already shares its profile between versions. Chromeria + // (toolboxmd fork): a fixed identity profile never imports another app's storage. + if (environment.isDevelopment || CHROMERIA_DESKTOP_IDENTITY.userDataProfile !== null) return; const marker = path.join(userDataPath, MARKER_FILE_NAME); if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return; yield* Ref.set(markerPath, Option.some(marker)); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 006dd97092..79ac52c6b0 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -1,5 +1,7 @@ import * as Option from "effect/Option"; +import { CHROMERIA_DESKTOP_IDENTITY } from "../../../../scripts/lib/chromeria-desktop-identity.ts"; + export type JoinPath = (first: string, ...segments: string[]) => string; function normalizeConfiguredBaseDir(t3Home: Option.Option): Option.Option { @@ -16,7 +18,7 @@ export function resolveDesktopBaseDir(input: { readonly t3Home: Option.Option; }): string { return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => - input.joinPath(input.homeDirectory, ".t3"), + input.joinPath(input.homeDirectory, CHROMERIA_DESKTOP_IDENTITY.homeDirName), ); } diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index bb94ee85ee..9251e917e9 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -4,6 +4,8 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { CHROMERIA_DESKTOP_IDENTITY } from "../../../../scripts/lib/chromeria-desktop-identity.ts"; + export class DesktopUserDataInitializationError extends Schema.TaggedError()( "DesktopUserDataInitializationError", { @@ -40,6 +42,9 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // Chromeria (toolboxmd fork): a fixed identity profile never migrates another profile. + const ownProfile = CHROMERIA_DESKTOP_IDENTITY.userDataProfile; + if (ownProfile !== null) return path.join(input.appDataDirectory, ownProfile); const names = input.isDevelopment ? { current: "t3code-dev", legacy: "T3 Code (Dev)" } : { current: "chromeria", legacy: "Chromeria" }; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7b2bf8a59f..b841187ce0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -12,12 +12,17 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import { CHROMERIA_DESKTOP_IDENTITY } from "../../../../scripts/lib/chromeria-desktop-identity.ts"; + export const DESKTOP_HOST = "app"; const DESKTOP_PRODUCTION_SCHEME = "t3code"; const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; export function getDesktopScheme(isDevelopment: boolean): string { - return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; + return ( + CHROMERIA_DESKTOP_IDENTITY.scheme ?? + (isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME) + ); } function getDesktopOrigin(isDevelopment: boolean): string { @@ -117,6 +122,24 @@ function withContentSecurityPolicy(response: Response, policy: string): Response * Must run synchronously during process bootstrap, before Electron emits `ready`. */ function registerDesktopSchemePrivilegesSync(): void { + // Chromeria (toolboxmd fork): an identity with its own scheme registers only that one. + const ownScheme = CHROMERIA_DESKTOP_IDENTITY.scheme; + if (ownScheme !== null) { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: ownScheme, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + ]); + return; + } Electron.protocol.registerSchemesAsPrivileged([ { scheme: DESKTOP_PRODUCTION_SCHEME, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 17ce805ffe..1b7311f13e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -2,9 +2,13 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; +import { desktopIdentityMarkerPlugin } from "../../scripts/lib/desktop-identity-marker.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { resolveChromeriaDesktopIdentity } from "../../scripts/lib/chromeria-desktop-identity.ts"; const repoEnv = loadRepoEnv(); +// Chromeria (toolboxmd fork): the identity this bundle embeds; rejects unknown variants. +const desktopIdentity = resolveChromeriaDesktopIdentity(process.env.CHROMERIA_DESKTOP_VARIANT); // The main process is bundled the same way the server CLI is: every JS // dependency is inlined and only packages Node must load from disk stay @@ -18,6 +22,7 @@ const publicConfigDefine = { __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", ), + __CHROMERIA_DESKTOP_VARIANT__: JSON.stringify(desktopIdentity.variant), }; export default defineConfig({ @@ -57,6 +62,7 @@ export default defineConfig({ define: publicConfigDefine, outputOptions: { codeSplitting: false }, entry: ["src/main.ts"], + plugins: [desktopIdentityMarkerPlugin(desktopIdentity.variant)], clean: true, deps: { alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), diff --git a/docs/fork-features.md b/docs/fork-features.md index c562e70858..e9e6ff052b 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -150,11 +150,12 @@ are evidence for review, not proof that a feature has been adopted upstream. ```json { "id": "branding", - "purpose": "Give desktop and web the Chromeria name, icons and independent desktop identity.", + "purpose": "Give desktop and web the Chromeria name, icons and independent desktop identities, including the side-by-side Chromeria V2 build.", "issues": [ "https://github.com/toolboxmd/t3code/issues/12", "https://github.com/toolboxmd/t3code/issues/13", - "https://github.com/toolboxmd/t3code/issues/14" + "https://github.com/toolboxmd/t3code/issues/14", + "https://github.com/toolboxmd/chromeria/issues/177" ], "prs": [ "https://github.com/toolboxmd/t3code/pull/12", @@ -162,23 +163,33 @@ are evidence for review, not proof that a feature has been adopted upstream. "https://github.com/toolboxmd/t3code/pull/14" ], "newFiles": [ + "apps/desktop/src/app/ChromeriaDesktopV2.test.ts", "apps/web/public/chromeria-mark.png", "assets/chromeria/chromeria-icon-1024.png", "assets/chromeria/chromeria-web-apple-touch-180.png", "assets/chromeria/chromeria-web-favicon-16x16.png", "assets/chromeria/chromeria-web-favicon-32x32.png", "assets/chromeria/chromeria-web-favicon.ico", - "assets/chromeria/chromeria-windows.ico" + "assets/chromeria/chromeria-windows.ico", + "packages/shared/src/codexAuthHandoff.chromeria.test.ts", + "scripts/lib/chromeria-desktop-identity.ts", + "scripts/lib/desktop-identity-marker.test.ts", + "scripts/lib/desktop-identity-marker.ts" ], "upstreamFiles": [ "apps/desktop/package.json", "apps/desktop/src/app/DesktopAppIdentity.test.ts", "apps/desktop/src/app/DesktopClerk.test.ts", + "apps/desktop/src/app/DesktopClerk.ts", + "apps/desktop/src/app/DesktopLegacyLocalStorage.ts", "apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts", "apps/desktop/src/app/DesktopEnvironment.ts", "apps/desktop/src/app/DesktopPreReadyPlatform.test.ts", + "apps/desktop/src/app/DesktopStatePaths.ts", "apps/desktop/src/app/DesktopUserData.test.ts", "apps/desktop/src/app/DesktopUserData.ts", + "apps/desktop/src/electron/ElectronProtocol.ts", + "apps/desktop/vite.config.ts", "apps/web/index.html", "apps/web/src/bootstrap.test.ts", "apps/web/src/branding.test.ts", @@ -192,6 +203,8 @@ are evidence for review, not proof that a feature has been adopted upstream. "apps/web/src/components/settings/ThemePreviewCircles.tsx", "apps/web/src/components/sidebar/SidebarChrome.tsx", "apps/web/src/lib/bootError.ts", + "packages/shared/src/codexAuthHandoff.ts", + "packages/shared/src/providerAuthReturnUrl.ts", "scripts/build-desktop-artifact.test.ts", "scripts/build-desktop-artifact.ts", "scripts/lib/brand-assets.test.ts", @@ -206,7 +219,10 @@ are evidence for review, not proof that a feature has been adopted upstream. "wordmark", "favicon", "Chromeria", - "auto-update" + "auto-update", + "chromeria-v2", + "setAsDefaultProtocolClient", + "requestSingleInstanceLock" ] } ``` diff --git a/docs/fork.md b/docs/fork.md index 723dbc9bb5..4d6321c03c 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -27,6 +27,26 @@ with its existing Electron `chromeria` profile. Development keeps upstream's identified by their embedded commit SHA; upstream package versions are unchanged. There is no Chromeria desktop update feed. +`CHROMERIA_DESKTOP_VARIANT=v2` builds Chromeria V2, which installs and runs beside +Chromeria on macOS. Its identity comes from one definition in +`scripts/lib/chromeria-desktop-identity.ts`: bundle id +`md.toolbox.chromeria.v2`, Electron profile `chromeria-v2`, only the +`chromeria-v2://` scheme, and default T3 home `~/.t3-v2`, where an explicit +`T3CODE_HOME` still wins. V2 imports no other profile and takes its own +single-instance lock before the Clerk bridge registers its scheme. Other platforms +and unknown variants fail the build. The packaged `package.json` records +`chromeriaDesktopVariant` beside the commit SHA. The desktop pack writes +`dist-electron/chromeria-desktop-identity.json` binding the variant to the +SHA-256 of `main.cjs`, and the artifact script refuses a mismatch, so +`--skip-build` cannot package a bundle built for another identity. + +The shared ChatGPT handoff reader and return allowlist accept `chromeria-v2://` +beside `t3code://`, and V2 reads only handoffs addressed to its own scheme. +Hosted app.t3.codes hands ChatGPT sign-in to the fixed `t3code://` scheme, so V2 +never receives it: the app that owns `t3code://` does, or the user pastes the +redirect URL. Settings → Install `t3` command in either app points the shared +`t3` link at that app's own launcher. + The server uses `chromeria-v2.sqlite` in its userdata directory. When missing, upstream initialization seeds it from a read-only sibling `state.sqlite` and imports V1 data into V2. Backfills run after shell import and before recovery; diff --git a/packages/shared/src/codexAuthHandoff.chromeria.test.ts b/packages/shared/src/codexAuthHandoff.chromeria.test.ts new file mode 100644 index 0000000000..506ba18bcd --- /dev/null +++ b/packages/shared/src/codexAuthHandoff.chromeria.test.ts @@ -0,0 +1,87 @@ +// Chromeria (toolboxmd fork): the V2 desktop's own chromeria-v2 scheme through the +// shared ChatGPT handoff and return helpers. +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { + codexAuthDeliveryUrl, + codexAuthHandoffUrl, + readCodexAuthDelivery, + readCodexAuthHandoff, +} from "./codexAuthHandoff.ts"; +import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts"; + +const authorizationUrl = new URL("https://auth.openai.com/api/accounts/authorize"); +authorizationUrl.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: "http://127.0.0.1:54213/auth/callback", + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), +}).toString(); +const input = { + authorizationUrl: authorizationUrl.toString(), + returnUrl: "chromeria-v2://app/settings/providers?instanceId=work-codex", + environmentId: EnvironmentId.make("remote-environment"), + instanceId: ProviderInstanceId.make("work-codex"), + flowId: "flow-one", +}; +const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=one-time-code`; + +describe("Chromeria V2 ChatGPT handoff", () => { + it("sanitizes V2 desktop return destinations like the default app's", () => { + expect(providerAuthReturnUrl("chromeria-v2://app/welcome?code=secret#agents:machine-id")).toBe( + "chromeria-v2://app/welcome#agents:machine-id", + ); + expect( + providerAuthReturnUrl("chromeria-v2://app/settings/providers?instanceId=work&code=secret"), + ).toBe("chromeria-v2://app/settings/providers?instanceId=work"); + for (const url of [ + "chromeria-v2://attacker/welcome", + "chromeria-v2://app:123/welcome", + "chromeria-v2://app/auth/callback", + "chromeria-v2://app/welcome/../evil", + "chromeria-v3://app/welcome", + ]) { + expect(providerAuthReturnUrl(url)).toBeUndefined(); + } + }); + + it("round-trips a V2 handoff and delivers the code to the V2 return destination", () => { + const link = codexAuthHandoffUrl(input, false, "chromeria-v2"); + expect(link.startsWith("chromeria-v2://auth/codex?request=")).toBe(true); + expect(readCodexAuthHandoff(link, false, "chromeria-v2")).toEqual(input); + + const delivery = codexAuthDeliveryUrl(input, callbackUrl); + expect(delivery.startsWith(`${input.returnUrl}#codex-auth=`)).toBe(true); + expect(readCodexAuthDelivery(delivery)).toEqual({ + callbackUrl, + environmentId: input.environmentId, + instanceId: input.instanceId, + flowId: input.flowId, + returnHash: "", + returnUrl: input.returnUrl, + }); + }); + + it("keeps each app to handoffs addressed to its own scheme", () => { + const v2 = codexAuthHandoffUrl(input, false, "chromeria-v2"); + expect(readCodexAuthHandoff(v2, false)).toBeUndefined(); + expect(readCodexAuthHandoff(v2, true)).toBeUndefined(); + expect(readCodexAuthHandoff(codexAuthHandoffUrl(input), false, "chromeria-v2")).toBeUndefined(); + expect( + readCodexAuthHandoff(codexAuthHandoffUrl(input, true), true, "chromeria-v2"), + ).toBeUndefined(); + expect( + readCodexAuthHandoff( + codexAuthHandoffUrl( + { ...input, returnUrl: "chromeria-v2://attacker/settings/providers" }, + false, + "chromeria-v2", + ), + false, + "chromeria-v2", + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/codexAuthHandoff.ts b/packages/shared/src/codexAuthHandoff.ts index d6632355e3..aa4f4a249c 100644 --- a/packages/shared/src/codexAuthHandoff.ts +++ b/packages/shared/src/codexAuthHandoff.ts @@ -84,18 +84,27 @@ export function codexCallbackUrl(value: string, redirectUri: string, state: stri return callback; } -export function codexAuthHandoffUrl(input: CodexAuthHandoff, development = false) { - const url = new URL(`${development ? "t3code-dev" : "t3code"}://auth/codex`); +// A desktop app with its own URL scheme (Chromeria V2) passes it explicitly. +export function codexAuthHandoffUrl( + input: CodexAuthHandoff, + development = false, + scheme = development ? "t3code-dev" : "t3code", +) { + const url = new URL(`${scheme}://auth/codex`); url.searchParams.set("request", encodeHandoff(input)); return url.toString(); } -export function readCodexAuthHandoff(value: string, development: boolean) { +export function readCodexAuthHandoff( + value: string, + development: boolean, + scheme = development ? "t3code-dev" : "t3code", +) { try { const url = new URL(value); if ( value.length > 32_768 || - url.protocol !== (development ? "t3code-dev:" : "t3code:") || + url.protocol !== `${scheme}:` || url.host !== "auth" || url.pathname !== "/codex" || url.username || diff --git a/packages/shared/src/providerAuthReturnUrl.ts b/packages/shared/src/providerAuthReturnUrl.ts index 5a0e825117..4419c5b3ef 100644 --- a/packages/shared/src/providerAuthReturnUrl.ts +++ b/packages/shared/src/providerAuthReturnUrl.ts @@ -5,7 +5,9 @@ export function providerAuthReturnUrl(value: string | undefined): string | undef if (!value) return undefined; try { const url = new URL(value); - const desktop = ["t3code:", "t3code-dev:"].includes(url.protocol) && url.host === "app"; + // chromeria-v2 is the Chromeria V2 desktop app's own scheme (toolboxmd fork). + const desktop = + ["t3code:", "t3code-dev:", "chromeria-v2:"].includes(url.protocol) && url.host === "app"; const web = ["http:", "https:"].includes(url.protocol) && (isLoopbackHost(url.hostname) || url.origin === "https://app.t3.codes"); diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b02458062d..718b79da4b 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -24,6 +24,7 @@ import { createBuildConfig, DESKTOP_ELECTRON_LANGUAGES, DESKTOP_FILE_EXCLUSIONS, + DesktopIdentityBuildError, DESKTOP_EXTRA_RESOURCES, LINUX_CAPTURE_EXTRA_RESOURCES, LINUX_BROWSER_SECRET_EXTRA_RESOURCES, @@ -91,6 +92,7 @@ import { WslRuntimeArchiveMissingError, wslRuntimeArchiveStem, } from "./build-desktop-artifact.ts"; +import { resolveChromeriaDesktopIdentity } from "./lib/chromeria-desktop-identity.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -1991,6 +1993,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it.effect("packages Chromeria V2 with its own identity and only its own scheme", () => + Effect.gen(function* () { + const v2 = resolveChromeriaDesktopIdentity("v2"); + const config = yield* createBuildConfig( + "mac", + "dmg", + "1.2.3-nightly.20260815.1", + false, + false, + undefined, + undefined, + false, + "arm64", + v2, + ); + + const mac = config.mac as Record; + assert.equal(config.appId, "md.toolbox.chromeria.v2"); + assert.equal(config.productName, "Chromeria V2"); + assert.equal(config.artifactName, "Chromeria-V2-${version}-${arch}.${ext}"); + assert.deepStrictEqual(mac.protocols, [{ name: "Chromeria V2", schemes: ["chromeria-v2"] }]); + assert.equal( + (config.dmg as Record).title, + "Chromeria V2 1.2.3-nightly.20260815.1 Installer", + ); + const signing = resolveMacPasskeySigningConfiguration( + { + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "clerk.example.com", + }, + v2, + ); + assert.equal(signing.appId, "md.toolbox.chromeria.v2"); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it.effect("uses the nightly DMG background for nightly macOS builds", () => Effect.gen(function* () { const config = yield* createBuildConfig( @@ -2322,6 +2361,41 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resolved.mockUpdates, false); }), ); + + it.effect("selects Chromeria V2 for macOS and rejects other platforms and variants", () => + Effect.gen(function* () { + const resolveFor = (platform: "mac" | "linux", variant: string) => + resolveBuildOptions({ + platform: Option.some(platform), + target: Option.none(), + arch: Option.some("arm64"), + buildVersion: Option.none(), + outputDir: Option.none(), + skipBuild: Option.none(), + keepStage: Option.none(), + signed: Option.none(), + verbose: Option.none(), + mockUpdates: Option.none(), + mockUpdateServerPort: Option.none(), + wslRuntime: Option.none(), + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { CHROMERIA_DESKTOP_VARIANT: variant } }), + ), + ), + ); + + assert.equal((yield* resolveFor("mac", "v2")).identity.appId, "md.toolbox.chromeria.v2"); + assert.equal((yield* resolveFor("linux", "")).identity.appId, "md.toolbox.chromeria"); + const linux = yield* Effect.flip(resolveFor("linux", "v2")); + assert.instanceOf(linux, DesktopIdentityBuildError); + assert.include(linux.message, "only for macOS"); + const unknown = yield* Effect.flip(resolveFor("mac", "v3")); + assert.instanceOf(unknown, DesktopIdentityBuildError); + assert.include(unknown.message, '"v3"'); + }), + ); }); // The self-containment check runs the packaged tree in a scratch directory. Its diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 62c77979c9..20b5b86ed5 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -29,6 +29,12 @@ import { type WebAssetBrand, } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; +import { desktopIdentityMarkerMismatch } from "./lib/desktop-identity-marker.ts"; +import { + CHROMERIA_DESKTOP_IDENTITIES, + resolveChromeriaDesktopIdentity, + type ChromeriaDesktopIdentity, +} from "./lib/chromeria-desktop-identity.ts"; import { findInlinedExternalPackages, selectCliRuntimeExternalDependencies, @@ -54,9 +60,6 @@ import { Command, Flag } from "effect/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; -// Chromeria (toolboxmd fork) uses its own bundle id so it installs beside -// upstream T3 Code instead of replacing it. -const DESKTOP_APP_ID = "md.toolbox.chromeria"; const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); @@ -495,6 +498,17 @@ export class BundledClientAssetsMissingError extends Schema.TaggedError()( + "DesktopIdentityBuildError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + export class UnsupportedDesktopBuildPlatformError extends Schema.TaggedError()( "UnsupportedDesktopBuildPlatformError", { @@ -921,6 +935,7 @@ interface ResolvedBuildOptions { readonly mockUpdates: boolean; readonly mockUpdateServerPort: number | undefined; readonly wslRuntime: string | undefined; + readonly identity: ChromeriaDesktopIdentity; } interface StagePackageJson { @@ -928,6 +943,7 @@ interface StagePackageJson { readonly version: string; readonly buildVersion: string; readonly t3codeCommitHash: string; + readonly chromeriaDesktopVariant: ChromeriaDesktopIdentity["variant"]; readonly private: true; readonly packageManager: string; readonly description: string; @@ -1240,6 +1256,7 @@ function normalizePasskeyRpDomain(value: string): string { export function resolveMacPasskeySigningConfiguration( env: Readonly>, + identity: ChromeriaDesktopIdentity = CHROMERIA_DESKTOP_IDENTITIES.default, ): MacPasskeySigningConfiguration { const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? ""; if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { @@ -1275,7 +1292,7 @@ export function resolveMacPasskeySigningConfiguration( } return { - appId: DESKTOP_APP_ID, + appId: identity.appId, teamId, rpDomains: uniqueRpDomains, provisioningProfilePath, @@ -1586,6 +1603,7 @@ const BuildEnvConfig = Config.all({ // by the build_linux_cli CI job. The Windows build embeds it verbatim as the // WSL runtime. wslRuntime: Config.String("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option), + desktopVariant: Config.String("CHROMERIA_DESKTOP_VARIANT").pipe(Config.option), }); const MockUpdateServerPortSchema = Schema.NumberFromString.check( @@ -1680,6 +1698,19 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const wslRuntime = Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime); + const identity = yield* Effect.try({ + try: () => resolveChromeriaDesktopIdentity(Option.getOrUndefined(env.desktopVariant)), + catch: (cause) => + new DesktopIdentityBuildError({ + detail: cause instanceof Error ? cause.message : String(cause), + }), + }); + if (identity.variant !== "default" && platform !== "mac") { + return yield* new DesktopIdentityBuildError({ + detail: `${identity.productName} builds only for macOS, not '${platform}'.`, + }); + } + return { platform, target, @@ -1693,6 +1724,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( mockUpdates, mockUpdateServerPort, wslRuntime, + identity, } satisfies ResolvedBuildOptions; }); @@ -2645,7 +2677,11 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { return `${trimmed.slice(0, versionSeparator)}/${trimmed.slice(versionSeparator + 1)}`; } -export function resolveDesktopProductName(version: string): string { +export function resolveDesktopProductName( + version: string, + identity: ChromeriaDesktopIdentity = CHROMERIA_DESKTOP_IDENTITIES.default, +): string { + if (identity.variant !== "default") return identity.productName; return resolveDesktopUpdateChannel(version) === "nightly" ? "T3 Code (Nightly)" : (desktopPackageJson.productName ?? "T3 Code"); @@ -2669,11 +2705,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // source file was never written fails the electron-builder step. wslRuntimeBundled = false, arch?: typeof BuildArch.Type, + identity: ChromeriaDesktopIdentity = CHROMERIA_DESKTOP_IDENTITIES.default, ) { + const productName = resolveDesktopProductName(version, identity); const buildConfig: Record = { - appId: DESKTOP_APP_ID, - productName: resolveDesktopProductName(version), - artifactName: "Chromeria-${version}-${arch}.${ext}", + appId: identity.appId, + productName, + artifactName: `${identity.artifactPrefix}-\${version}-\${arch}.\${ext}`, electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], files: [ ...DESKTOP_FILE_EXCLUSIONS, @@ -2724,14 +2762,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( icon: "icon.icns", category: "public.app-category.developer-tools", extendInfo: { - NSScreenCaptureUsageDescription: - "Chromeria captures the active window when you use the window capture shortcut.", + NSScreenCaptureUsageDescription: `${identity.productName} captures the active window when you use the window capture shortcut.`, }, protocols: [ - { - name: "T3 Code", - schemes: ["t3code", "t3code-dev"], - }, + identity.scheme === null + ? { name: "T3 Code", schemes: ["t3code", "t3code-dev"] } + : { name: identity.productName, schemes: [identity.scheme] }, ], ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), ...(macPasskeySigning @@ -2748,7 +2784,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // Give the themed installer its own Finder volume name. Finder caches // DMG window backgrounds by volume name, so reusing a generic name can // make a newly built background look unchanged during testing. - title: `${resolveDesktopProductName(version)} ${version} Installer`, + title: `${productName} ${version} Installer`, background: `dmg/dmg-background-${updateChannel}.png`, window: { width: 640, @@ -3510,6 +3546,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } } + const identityMismatch = desktopIdentityMarkerMismatch( + distDirs.desktopDist, + options.identity.variant, + ); + if (identityMismatch !== undefined) { + return yield* new DesktopIdentityBuildError({ + detail: `Cannot package ${options.identity.productName}: ${identityMismatch}. Rebuild with CHROMERIA_DESKTOP_VARIANT=${options.identity.variant} and without --skip-build.`, + }); + } // Assert against the emitted bundle, not the bundler config. `alwaysBundle` // only forces packages IN, so a transitive dependency of an external package @@ -3660,7 +3705,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const configuredMacPasskeySigning = options.platform === "mac" && options.signed ? yield* Effect.try({ - try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), + try: () => + resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot }), options.identity), catch: MacPasskeySigningConfigurationResolutionError.fromCause, }) : undefined; @@ -3712,6 +3758,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( version: appVersion, buildVersion: appVersion, t3codeCommitHash: commitHash, + chromeriaDesktopVariant: options.identity.variant, private: true, packageManager: rootPackageJson.packageManager, description: @@ -3736,6 +3783,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( : undefined, bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }), options.arch, + options.identity, ), dependencies: stageDependencies, devDependencies: { diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 422e304b6f..aa333e732e 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -5,11 +5,16 @@ AGENTS.md apps/desktop/package.json apps/desktop/src/app/DesktopAppIdentity.test.ts apps/desktop/src/app/DesktopClerk.test.ts +apps/desktop/src/app/DesktopClerk.ts apps/desktop/src/app/DesktopEnvironment.ts +apps/desktop/src/app/DesktopLegacyLocalStorage.ts apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +apps/desktop/src/app/DesktopStatePaths.ts apps/desktop/src/app/DesktopUserData.test.ts apps/desktop/src/app/DesktopUserData.ts +apps/desktop/src/electron/ElectronProtocol.ts +apps/desktop/vite.config.ts apps/server/src/auth/PairingGrantStore.test.ts apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts apps/server/src/auth/RpcAuthorization.ts @@ -103,6 +108,8 @@ packages/contracts/src/orchestrationV2.ts packages/contracts/src/orchestratorMcp.ts packages/contracts/src/rpc.ts packages/contracts/src/settings.ts +packages/shared/src/codexAuthHandoff.ts +packages/shared/src/providerAuthReturnUrl.ts scripts/build-desktop-artifact.test.ts scripts/build-desktop-artifact.ts scripts/lib/brand-assets.test.ts diff --git a/scripts/lib/chromeria-desktop-identity.ts b/scripts/lib/chromeria-desktop-identity.ts new file mode 100644 index 0000000000..2d2065589e --- /dev/null +++ b/scripts/lib/chromeria-desktop-identity.ts @@ -0,0 +1,58 @@ +// Chromeria (toolboxmd fork): the desktop identity a build embeds. The packaged +// app, its Electron profile, URL scheme and default T3 home all come from this +// one definition, selected at build time with CHROMERIA_DESKTOP_VARIANT. +// Dependency-free: the desktop runtime, its vite config and the artifact script share it. + +declare const __CHROMERIA_DESKTOP_VARIANT__: string | undefined; + +export interface ChromeriaDesktopIdentity { + readonly variant: "default" | "v2"; + readonly productName: string; + readonly appId: string; + readonly artifactPrefix: string; + /** + * A fixed Electron profile in every mode that never imports another app's + * profile. Null keeps upstream's per-mode profile and its V1 imports. + */ + readonly userDataProfile: string | null; + /** The only URL scheme the app registers. Null keeps upstream's t3code and t3code-dev. */ + readonly scheme: string | null; + /** Default T3 home below the user's home directory; T3CODE_HOME still wins. */ + readonly homeDirName: string; +} + +export const CHROMERIA_DESKTOP_IDENTITIES = { + default: { + variant: "default", + productName: "Chromeria", + appId: "md.toolbox.chromeria", + artifactPrefix: "Chromeria", + userDataProfile: null, + scheme: null, + homeDirName: ".t3", + }, + // Installs and runs beside the default app, so it shares none of its identity. + v2: { + variant: "v2", + productName: "Chromeria V2", + appId: "md.toolbox.chromeria.v2", + artifactPrefix: "Chromeria-V2", + userDataProfile: "chromeria-v2", + scheme: "chromeria-v2", + homeDirName: ".t3-v2", + }, +} as const satisfies Record; + +/** Unset or empty selects the default app; any unknown value is a build error. */ +export function resolveChromeriaDesktopIdentity( + variant: string | undefined, +): ChromeriaDesktopIdentity { + const value = variant?.trim() || "default"; + if (value === "default" || value === "v2") return CHROMERIA_DESKTOP_IDENTITIES[value]; + throw new Error(`Unknown CHROMERIA_DESKTOP_VARIANT "${value}". Use "v2" or leave it unset.`); +} + +/** The identity this bundle was built with. Tests and unbundled runs get the default. */ +export const CHROMERIA_DESKTOP_IDENTITY = resolveChromeriaDesktopIdentity( + typeof __CHROMERIA_DESKTOP_VARIANT__ === "undefined" ? undefined : __CHROMERIA_DESKTOP_VARIANT__, +); diff --git a/scripts/lib/desktop-identity-marker.test.ts b/scripts/lib/desktop-identity-marker.test.ts new file mode 100644 index 0000000000..014e7a30e6 --- /dev/null +++ b/scripts/lib/desktop-identity-marker.test.ts @@ -0,0 +1,80 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests write a scratch dist-electron with Node's filesystem. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import { afterEach } from "vite-plus/test"; + +import { + DESKTOP_IDENTITY_MARKER, + desktopIdentityMarkerMismatch, + desktopIdentityMarkerPlugin, +} from "./desktop-identity-marker.ts"; + +const dirs: string[] = []; +const distDir = () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "chromeria-identity-marker-")); + dirs.push(dir); + return dir; +}; +const writeMain = (dir: string, source: string) => + NodeFS.writeFileSync(NodePath.join(dir, "main.cjs"), source); +const packMain = (dir: string, variant: string) => + desktopIdentityMarkerPlugin(variant).writeBundle({ dir }); + +afterEach(() => { + for (const dir of dirs.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true }); +}); + +describe("desktop identity marker", () => { + it("accepts the main bundle its own pack wrote", () => { + const dir = distDir(); + writeMain(dir, 'const variant = "v2";'); + packMain(dir, "v2"); + + assert.isUndefined(desktopIdentityMarkerMismatch(dir, "v2")); + }); + + it("rejects a bundle built for another variant", () => { + const dir = distDir(); + writeMain(dir, 'const variant = "default";'); + packMain(dir, "default"); + + assert.include(desktopIdentityMarkerMismatch(dir, "v2"), 'built for variant "default"'); + }); + + it("rejects a bundle without a marker, as left by a failed or pre-identity pack", () => { + const dir = distDir(); + writeMain(dir, 'const variant = "v2";'); + + assert.include(desktopIdentityMarkerMismatch(dir, "v2"), "missing or unreadable"); + + const failed = distDir(); + assert.throws(() => packMain(failed, "v2")); + assert.isFalse(NodeFS.existsSync(NodePath.join(failed, DESKTOP_IDENTITY_MARKER))); + }); + + it("rejects a main bundle replaced or removed after its marker was written", () => { + const dir = distDir(); + writeMain(dir, 'const variant = "v2";'); + packMain(dir, "v2"); + + writeMain(dir, 'const variant = "default";'); + assert.include(desktopIdentityMarkerMismatch(dir, "v2"), "does not match"); + + NodeFS.rmSync(NodePath.join(dir, "main.cjs")); + assert.include(desktopIdentityMarkerMismatch(dir, "v2"), "main.cjs is missing"); + }); + + it("rejects a marker edited to claim another variant", () => { + const dir = distDir(); + writeMain(dir, 'const variant = "default";'); + packMain(dir, "default"); + const markerPath = NodePath.join(dir, DESKTOP_IDENTITY_MARKER); + const marker = JSON.parse(NodeFS.readFileSync(markerPath, "utf8")); + NodeFS.writeFileSync(markerPath, JSON.stringify({ ...marker, variant: "v2", mainSha256: "0" })); + + assert.include(desktopIdentityMarkerMismatch(dir, "v2"), "does not match"); + }); +}); diff --git a/scripts/lib/desktop-identity-marker.ts b/scripts/lib/desktop-identity-marker.ts new file mode 100644 index 0000000000..f7745679e6 --- /dev/null +++ b/scripts/lib/desktop-identity-marker.ts @@ -0,0 +1,60 @@ +// @effect-diagnostics nodeBuiltinImport:off - The desktop pack writes this marker from a bundler hook, outside an Effect runtime. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +// Chromeria (toolboxmd fork): `--skip-build` packages whatever dist-electron +// holds. main.cjs is the only desktop bundle that embeds the identity (preload +// reads branding over IPC), so the marker binds its exact bytes to the variant +// it was built for, and packaging refuses any other pairing. +export const DESKTOP_IDENTITY_MARKER = "chromeria-desktop-identity.json"; +const MAIN_BUNDLE = "main.cjs"; + +const sha256 = (file: string) => + NodeCrypto.createHash("sha256").update(NodeFS.readFileSync(file)).digest("hex"); + +export function writeDesktopIdentityMarker(distDir: string, variant: string): void { + const mainSha256 = sha256(NodePath.join(distDir, MAIN_BUNDLE)); + NodeFS.writeFileSync( + NodePath.join(distDir, DESKTOP_IDENTITY_MARKER), + `${JSON.stringify({ variant, mainSha256 })}\n`, + ); +} + +/** For the main.ts pack only: runs after main.cjs is written, so a failed pack leaves no marker. */ +export function desktopIdentityMarkerPlugin(variant: string) { + return { + name: "chromeria-desktop-identity-marker", + writeBundle(options: { readonly dir?: string | undefined }) { + if (!options.dir) throw new Error("The desktop identity marker needs an output directory."); + writeDesktopIdentityMarker(options.dir, variant); + }, + }; +} + +/** Why dist-electron cannot be packaged as `variant`, or undefined when it can. */ +export function desktopIdentityMarkerMismatch( + distDir: string, + variant: string, +): string | undefined { + let marker: { readonly variant?: unknown; readonly mainSha256?: unknown }; + try { + marker = JSON.parse( + NodeFS.readFileSync(NodePath.join(distDir, DESKTOP_IDENTITY_MARKER), "utf8"), + ); + } catch { + return `${DESKTOP_IDENTITY_MARKER} is missing or unreadable`; + } + if (marker.variant !== variant) { + return `${MAIN_BUNDLE} was built for variant "${String(marker.variant)}", not "${variant}"`; + } + let mainSha256: string; + try { + mainSha256 = sha256(NodePath.join(distDir, MAIN_BUNDLE)); + } catch { + return `${MAIN_BUNDLE} is missing`; + } + return marker.mainSha256 === mainSha256 + ? undefined + : `${MAIN_BUNDLE} does not match its identity marker`; +}