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`; +}