diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 365363881..ee2b5a2e9 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,6 +15,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as ForkRendererStorageMigration from "./ForkRendererStorageMigration.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -167,6 +168,23 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; + // Fork: before the renderer scheme gets its real handler, carry browser storage + // over from the fork's old renderer origin. A failure only costs that storage. + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* appIdentity.resolveUserDataPath.pipe( + Effect.flatMap((userDataPath) => + ForkRendererStorageMigration.migrateLegacyRendererStorage({ + legacyScheme: ElectronProtocol.getDesktopLinkScheme(environment.isDevelopment), + currentScheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + host: ElectronProtocol.DESKTOP_HOST, + userDataPath, + }), + ), + Effect.tap((result) => logBootstrapInfo("fork renderer storage migration", { result })), + Effect.catch((error) => + logBootstrapWarning("fork renderer storage migration failed", { error }), + ), + ); // The renderer is served from the bundled client (or Vite in development) // rather than through the local backend, so the window can open without one. const electronProtocol = yield* ElectronProtocol.ElectronProtocol; diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 072a1871d..329ac9f9a 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -76,8 +76,12 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), passkeys: true, + // Fork: the bridge registers this scheme as the OS link handler and uses it for + // the OAuth return link, so it gets the fork's link scheme, not the renderer's + // t3code. The official install keeps t3code://, and an OAuth sign-in that Clerk + // returns to t3code://app/ does not reach the fork. Email sign-in needs no link. renderer: { - scheme: ElectronProtocol.getDesktopScheme(isDevelopment), + scheme: ElectronProtocol.getDesktopLinkScheme(isDevelopment), host: ElectronProtocol.DESKTOP_HOST, }, }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 404aff34c..c48226e1c 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -96,7 +96,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + const scheme = ElectronProtocol.getDesktopLinkScheme(environment.isDevelopment); const desktopEntryPath = environment.path.join( environment.linuxApplicationsDir, environment.linuxDesktopEntryName, diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33..99103a46f 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -76,7 +76,7 @@ export const make = Effect.gen(function* () { appVersion: Electron.app.getVersion(), }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, - scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), + scheme: ElectronProtocol.getDesktopLinkScheme(linux.isDevelopment), }), "utf8", ); diff --git a/apps/desktop/src/app/ForkRendererStorageMigration.test.ts b/apps/desktop/src/app/ForkRendererStorageMigration.test.ts new file mode 100644 index 000000000..b4e507901 --- /dev/null +++ b/apps/desktop/src/app/ForkRendererStorageMigration.test.ts @@ -0,0 +1,198 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import { beforeEach, vi } from "vite-plus/test"; + +// Each origin gets its own localStorage, as in Chromium. The view reads whichever +// origin it last loaded, and runs the module's write script against that store. +const { origins, handleMock, unhandleMock, closeMock, flushMock, state } = vi.hoisted(() => { + const origins = new Map>(); + return { + origins, + handleMock: vi.fn(), + unhandleMock: vi.fn(), + closeMock: vi.fn(), + flushMock: vi.fn(), + state: { loaded: "", corruptOrigin: "" }, + }; +}); + +vi.mock("electron", () => { + const storeFor = (origin: string) => { + let store = origins.get(origin); + if (!store) { + store = new Map(); + origins.set(origin, store); + } + return store; + }; + class WebContentsView { + webContents = { + loadURL: async (url: string) => { + state.loaded = url; + }, + executeJavaScript: async (code: string) => { + const { protocol, host } = new URL(state.loaded); + const origin = `${protocol}//${host}`; + const store = storeFor(origin); + if (code === "Object.entries(window.localStorage)") { + return origin === state.corruptOrigin ? [["key", 1]] : [...store.entries()]; + } + const localStorage = { setItem: (key: string, value: string) => store.set(key, value) }; + new Function("window", code)({ localStorage }); + return undefined; + }, + close: closeMock, + session: { flushStorageData: flushMock }, + }; + } + return { protocol: { handle: handleMock, unhandle: unhandleMock }, WebContentsView }; +}); + +import { + MIGRATION_MARKER_FILE, + migrateLegacyRendererStorage, + planLegacyStorageCopy, +} from "./ForkRendererStorageMigration.ts"; +import { + DESKTOP_HOST, + getDesktopLinkScheme, + getDesktopScheme, +} from "../electron/ElectronProtocol.ts"; + +const LEGACY_SCHEME = getDesktopLinkScheme(false); +const CURRENT_SCHEME = getDesktopScheme(false); +const LEGACY = `${LEGACY_SCHEME}://${DESKTOP_HOST}`; +const CURRENT = `${CURRENT_SCHEME}://${DESKTOP_HOST}`; + +const migrate = (userDataPath: string) => + migrateLegacyRendererStorage({ + legacyScheme: LEGACY_SCHEME, + currentScheme: CURRENT_SCHEME, + host: DESKTOP_HOST, + userDataPath, + }); + +describe("planLegacyStorageCopy", () => { + it("copies only the keys the new origin does not hold", () => { + assert.deepStrictEqual( + planLegacyStorageCopy( + [ + ["t3code:composer-drafts:v1", "old draft"], + ["t3code:theme", "dark"], + ], + [["t3code:theme", "light"]], + ), + [["t3code:composer-drafts:v1", "old draft"]], + ); + }); +}); + +describe("migrateLegacyRendererStorage", () => { + beforeEach(() => { + origins.clear(); + handleMock.mockReset(); + unhandleMock.mockReset(); + closeMock.mockReset(); + flushMock.mockReset(); + state.loaded = ""; + state.corruptOrigin = ""; + }); + + it.effect("moves drafts, stash, themes and folds to the new origin once", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const userData = yield* fileSystem.makeTempDirectoryScoped(); + origins.set( + LEGACY, + new Map([ + ["t3code:composer-drafts:v1", '{"thread-1":"half-written prompt"}'], + ["t3code:prompt-stash:v2", '["stashed"]'], + ["t3code:themes:v1", '[{"id":"mine"}]'], + ["t3code:ui-state:v1", '{"fleetRepoExpandedById":{"t3code":false}}'], + ["t3code:sidebar:settled-expanded", "true"], + ["t3code:theme", "dark"], + ]), + ); + origins.set(CURRENT, new Map([["t3code:theme", "light"]])); + + const first = yield* migrate(userData); + assert.deepStrictEqual(first, { _tag: "Copied", copied: 5, skipped: 1 }); + const current = origins.get(CURRENT)!; + assert.equal(current.get("t3code:composer-drafts:v1"), '{"thread-1":"half-written prompt"}'); + assert.equal(current.get("t3code:prompt-stash:v2"), '["stashed"]'); + assert.equal(current.get("t3code:themes:v1"), '[{"id":"mine"}]'); + assert.equal(current.get("t3code:ui-state:v1"), '{"fleetRepoExpandedById":{"t3code":false}}'); + // A value the new origin already holds wins over the old one. + assert.equal(current.get("t3code:theme"), "light"); + assert.isTrue(yield* fileSystem.exists(`${userData}/${MIGRATION_MARKER_FILE}`)); + // The blank pages are gone again, so the real renderer handler can register. + assert.deepStrictEqual( + unhandleMock.mock.calls.map(([scheme]) => scheme).toSorted(), + [CURRENT_SCHEME, LEGACY_SCHEME].toSorted(), + ); + assert.equal(closeMock.mock.calls.length, 1); + // The copy is committed to disk before the marker is written. + assert.equal(flushMock.mock.calls.length, 1); + + // A later edit at the new origin survives the next launch. + current.set("t3code:composer-drafts:v1", "newer draft"); + const second = yield* migrate(userData); + assert.deepStrictEqual(second, { _tag: "AlreadyMigrated" }); + assert.equal(current.get("t3code:composer-drafts:v1"), "newer draft"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("stops retrying a failing copy after three launches", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const userData = yield* fileSystem.makeTempDirectoryScoped(); + const markerPath = `${userData}/${MIGRATION_MARKER_FILE}`; + state.corruptOrigin = LEGACY; + + for (const launch of [1, 2, 3]) { + const exit = yield* Effect.exit(migrate(userData)); + assert.isTrue(Exit.isFailure(exit)); + assert.equal((yield* fileSystem.readFileString(markerPath)).trim(), `failed ${launch}`); + } + const pagesLoadedBefore = handleMock.mock.calls.length; + + // The fourth launch skips the copy and opens the window without waiting on it. + assert.deepStrictEqual(yield* migrate(userData), { _tag: "GaveUp", attempts: 3 }); + assert.equal(handleMock.mock.calls.length, pagesLoadedBefore); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("a retry after a failed launch still never overwrites the new origin", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const userData = yield* fileSystem.makeTempDirectoryScoped(); + origins.set( + LEGACY, + new Map([ + ["t3code:composer-drafts:v1", "old draft"], + ["t3code:theme", "dark"], + ]), + ); + origins.set(CURRENT, new Map([["t3code:theme", "light"]])); + state.corruptOrigin = LEGACY; + assert.isTrue(Exit.isFailure(yield* Effect.exit(migrate(userData)))); + + state.corruptOrigin = ""; + assert.deepStrictEqual(yield* migrate(userData), { + _tag: "Copied", + copied: 1, + skipped: 1, + }); + const current = origins.get(CURRENT)!; + assert.equal(current.get("t3code:composer-drafts:v1"), "old draft"); + assert.equal(current.get("t3code:theme"), "light"); + assert.equal( + (yield* fileSystem.readFileString(`${userData}/${MIGRATION_MARKER_FILE}`)).trim(), + "copied", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/app/ForkRendererStorageMigration.ts b/apps/desktop/src/app/ForkRendererStorageMigration.ts new file mode 100644 index 000000000..5f71fc77f --- /dev/null +++ b/apps/desktop/src/app/ForkRendererStorageMigration.ts @@ -0,0 +1,179 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; + +/** + * Fork-only: the fork used to serve its renderer from t3code-fork://app. It now + * uses upstream's t3code://app, the only origin T3's Clerk instance accepts for + * T3 Connect. Browser storage belongs to an origin, so composer drafts, the prompt + * stash, custom themes and sidebar folds stayed behind at the old one. This copies + * the old origin's localStorage into the new one once, before the main window + * loads, and never overwrites a key the new origin already holds. + */ + +export type StorageEntries = ReadonlyArray; + +/** + * Written to the userData directory after each run: "copied" once the copy is + * done, or "failed " after n failed launches. Startup stops trying after + * MAX_MIGRATION_ATTEMPTS failures, so a lasting fault cannot delay every launch. + */ +export const MIGRATION_MARKER_FILE = "fork-renderer-storage-migrated"; +const MAX_MIGRATION_ATTEMPTS = 3; + +const MIGRATION_TIMEOUT = Duration.seconds(10); +const BLANK_PAGE = ""; +const READ_LOCAL_STORAGE = "Object.entries(window.localStorage)"; + +const StorageEntriesSchema = Schema.Array(Schema.Tuple([Schema.String, Schema.String])); +const decodeStorageEntries = Schema.decodeUnknownEffect(StorageEntriesSchema); +const encodeStorageEntriesJson = Schema.encodeEffect(Schema.fromJsonString(StorageEntriesSchema)); + +export class ForkRendererStorageMigrationError extends Schema.TaggedError()( + "ForkRendererStorageMigrationError", + { + step: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Copying browser storage from the old renderer origin failed at: ${this.step}.`; + } +} + +/** The legacy entries to write: every key the current origin does not hold yet. */ +export function planLegacyStorageCopy(legacy: StorageEntries, current: StorageEntries) { + const present = new Set(current.map(([key]) => key)); + return legacy.filter(([key]) => !present.has(key)); +} + +export type ForkRendererStorageMigrationResult = + | { readonly _tag: "AlreadyMigrated" } + | { readonly _tag: "GaveUp"; readonly attempts: number } + | { readonly _tag: "Copied"; readonly copied: number; readonly skipped: number }; + +/** How many launches have failed so far, or "copied" once the copy is done. */ +function parseMigrationMarker(text: string | undefined) { + const line = text?.trim() ?? ""; + if (line === "copied") return "copied"; + const failed = /^failed (\d+)$/.exec(line); + return failed ? Number(failed[1]) : 0; +} + +const serveBlankPage = () => + new Response(BLANK_PAGE, { headers: { "content-type": "text/html; charset=utf-8" } }); + +/** + * Runs the copy once per userData directory. Call it after `ready` and before the + * renderer scheme gets its real handler: both schemes are served a blank page + * while it runs, so neither origin boots the app. It loads the pages in a + * WebContentsView rather than a window, because closing the last window quits + * the app on Windows and Linux. + */ +export const migrateLegacyRendererStorage = Effect.fn("desktop.fork.migrateRendererStorage")( + function* (input: { + readonly legacyScheme: string; + readonly currentScheme: string; + readonly host: string; + readonly userDataPath: string; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const markerPath = path.join(input.userDataPath, MIGRATION_MARKER_FILE); + const marker = parseMigrationMarker( + yield* fileSystem.readFileString(markerPath).pipe(Effect.orElseSucceed(() => undefined)), + ); + if (marker === "copied") { + return { _tag: "AlreadyMigrated" } satisfies ForkRendererStorageMigrationResult; + } + if (marker >= MAX_MIGRATION_ATTEMPTS) { + return { _tag: "GaveUp", attempts: marker } satisfies ForkRendererStorageMigrationResult; + } + + const failedAt = (step: string) => (cause: unknown) => + new ForkRendererStorageMigrationError({ step, cause }); + + const result = yield* Effect.scoped( + Effect.gen(function* () { + for (const scheme of [input.legacyScheme, input.currentScheme]) { + yield* Effect.acquireRelease( + Effect.try({ + try: () => Electron.protocol.handle(scheme, serveBlankPage), + catch: failedAt(`serve ${scheme}`), + }), + () => Effect.sync(() => Electron.protocol.unhandle(scheme)), + ); + } + const view = yield* Effect.acquireRelease( + Effect.try({ + try: () => + new Electron.WebContentsView({ + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false }, + }), + catch: failedAt("create view"), + }), + (view) => Effect.sync(() => view.webContents.close()), + ); + const readAt = (scheme: string) => + Effect.tryPromise({ + try: async () => { + await view.webContents.loadURL(`${scheme}://${input.host}/`); + return (await view.webContents.executeJavaScript(READ_LOCAL_STORAGE)) as unknown; + }, + catch: failedAt(`read ${scheme}`), + }).pipe( + Effect.flatMap((raw) => + decodeStorageEntries(raw).pipe(Effect.mapError(failedAt(`decode ${scheme}`))), + ), + ); + + const legacy = yield* readAt(input.legacyScheme); + const current = yield* readAt(input.currentScheme); + const toCopy = planLegacyStorageCopy(legacy, current); + if (toCopy.length > 0) { + // JSON is a valid JavaScript expression, so the entries go in as a literal. + const literal = yield* encodeStorageEntriesJson(toCopy).pipe( + Effect.mapError(failedAt(`encode ${input.currentScheme}`)), + ); + yield* Effect.tryPromise({ + try: () => + view.webContents.executeJavaScript( + `for (const [key, value] of ${literal}) window.localStorage.setItem(key, value);`, + ), + catch: failedAt(`write ${input.currentScheme}`), + }); + // Chromium commits localStorage to disk later. Commit now, so a crash right + // after this cannot lose the copy once the marker says it is done. + yield* Effect.try({ + try: () => view.webContents.session.flushStorageData(), + catch: failedAt("flush storage"), + }); + } + return { + _tag: "Copied", + copied: toCopy.length, + skipped: legacy.length - toCopy.length, + } satisfies ForkRendererStorageMigrationResult; + }), + ).pipe( + Effect.timeoutOrElse({ + duration: MIGRATION_TIMEOUT, + orElse: () => + Effect.fail(new ForkRendererStorageMigrationError({ step: "timeout", cause: null })), + }), + // Count the failure, so the next launches stop after MAX_MIGRATION_ATTEMPTS. + Effect.tapError(() => + fileSystem.writeFileString(markerPath, `failed ${marker + 1}\n`).pipe(Effect.ignore), + ), + ); + + yield* fileSystem + .writeFileString(markerPath, "copied\n") + .pipe(Effect.mapError(failedAt("write marker"))); + return result; + }, +); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 508a5c296..352f47065 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -28,6 +28,16 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it("serves the renderer from upstream's origin and keeps the fork's link scheme", () => { + // T3's Clerk instance accepts t3code://app, not t3code-fork://app, so T3 Connect + // sign-in only loads from upstream's origin. + assert.equal(ElectronProtocol.getDesktopUrl(false), "t3code://app/"); + assert.equal(ElectronProtocol.getDesktopScheme(true), "t3code-dev"); + // The OS link handler stays the fork's, so an official install keeps t3code://. + assert.equal(ElectronProtocol.getDesktopLinkScheme(false), "t3code-fork"); + assert.equal(ElectronProtocol.getDesktopLinkScheme(true), "t3code-fork-dev"); + }); + it.effect("serves the bundled client from disk without a backend", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index b1bd16dc6..db03825e0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -13,17 +13,28 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; export const DESKTOP_HOST = "app"; -// Distinct from upstream's "t3code": this is registered as the OS-level default -// protocol handler (app.setAsDefaultProtocolClient), so sharing the scheme with an -// official T3 Code install would let whichever app registered most recently steal -// the other's t3code:// links. -const DESKTOP_PRODUCTION_SCHEME = "t3code-fork"; -export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-fork-dev"; +// The renderer is served from upstream's origin. T3's production Clerk instance +// only accepts requests from t3code://app, so T3 Connect sign-in needs it. +const DESKTOP_PRODUCTION_SCHEME = "t3code"; +export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; +// Fork-only: the scheme registered as the OS-level link handler +// (app.setAsDefaultProtocolClient, the installer's protocol list, the Linux +// desktop entry). Distinct from upstream's so an official T3 Code install keeps +// its t3code:// links. The fork once served the renderer from it too, so its +// origin still holds older browser storage (see ForkRendererStorageMigration). +const DESKTOP_PRODUCTION_LINK_SCHEME = "t3code-fork"; +const DESKTOP_DEVELOPMENT_LINK_SCHEME = "t3code-fork-dev"; +/** The scheme the renderer is served from. It decides the window's origin. */ export function getDesktopScheme(isDevelopment: boolean): string { return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; } +/** The scheme the OS routes to this app. Never upstream's, see above. */ +export function getDesktopLinkScheme(isDevelopment: boolean): string { + return isDevelopment ? DESKTOP_DEVELOPMENT_LINK_SCHEME : DESKTOP_PRODUCTION_LINK_SCHEME; +} + function getDesktopOrigin(isDevelopment: boolean): string { return `${getDesktopScheme(isDevelopment)}://${DESKTOP_HOST}`; } @@ -120,19 +131,17 @@ function withContentSecurityPolicy(response: Response, policy: string): Response * Must run synchronously during process bootstrap, before Electron emits `ready`. */ function registerDesktopSchemePrivilegesSync(): void { - Electron.protocol.registerSchemesAsPrivileged([ - { - scheme: DESKTOP_PRODUCTION_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - stream: true, - }, - }, - { - scheme: DESKTOP_DEVELOPMENT_SCHEME, + // Fork: the link schemes are listed too. The storage migration loads a page from + // the old t3code-fork://app origin, and web storage only exists on a standard + // scheme. Electron allows this call once, so every scheme belongs in this list. + Electron.protocol.registerSchemesAsPrivileged( + [ + DESKTOP_PRODUCTION_SCHEME, + DESKTOP_DEVELOPMENT_SCHEME, + DESKTOP_PRODUCTION_LINK_SCHEME, + DESKTOP_DEVELOPMENT_LINK_SCHEME, + ].map((scheme) => ({ + scheme, privileges: { standard: true, secure: true, @@ -140,8 +149,8 @@ function registerDesktopSchemePrivilegesSync(): void { corsEnabled: true, stream: true, }, - }, - ]); + })), + ); } const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe( diff --git a/docs/operations/fork-windows-build.md b/docs/operations/fork-windows-build.md index 90f57df5a..34783843f 100644 --- a/docs/operations/fork-windows-build.md +++ b/docs/operations/fork-windows-build.md @@ -51,10 +51,18 @@ so a later upstream change to `.env.example` reaches this machine only once you hand-written `.env` or `.env.local` is yours in full: when it is missing one of the four values, T3 Connect stays off, because nothing fills a single missing key from `.env.example`. -Known risk: the fork's OAuth callback scheme is `t3code-fork://` and the official Clerk instance -allowlists `t3code://app/` and `t3code-dev://app/` only, so a Google or GitHub sign-in may open the -browser and never come back, while email code sign-in stays in the window -([#137](https://github.com/autoprintworks/t3code/issues/137)). +The build values are not enough on their own. T3's production Clerk instance only accepts requests +from `t3code://app`, and answers any other origin with `origin_invalid`, so T3 Connect sits at +"Loading sign-in..." forever. The fork therefore serves its window from upstream's `t3code://app` and +keeps `t3code-fork://` only as the link scheme the OS routes to it (see +[`ElectronProtocol.ts`](../../apps/desktop/src/electron/ElectronProtocol.ts)). The first launch after +that change copies browser storage (drafts, prompt stash, themes, sidebar folds) from the old +`t3code-fork://app` origin, in +[`ForkRendererStorageMigration.ts`](../../apps/desktop/src/app/ForkRendererStorageMigration.ts). + +Known limit: Clerk sends an OAuth sign-in back to `t3code://app/`, which belongs to the official +install, so a Google or GitHub sign-in does not come back to the fork. Sign in with email and password, +which stays in the window. ## The collision decision — read before running the installer @@ -71,10 +79,11 @@ convenience of a shared thread list on day one. So the fork now gets its own ide `%LOCALAPPDATA%\Programs\t3code`, where an official release lives. Installing this build cannot replace or corrupt an official install's files. - **Different app identity.** Product name (`T3 Code Fork`), Windows AppUserModelID - (`com.autoprintworks.t3code`), and the custom URL scheme used for OAuth callbacks + (`com.autoprintworks.t3code`), and the link scheme the OS routes to the app (`t3code-fork://` / `t3code-fork-dev://`, see `apps/desktop/src/electron/ElectronProtocol.ts`) are all distinct from the official build's. Two different OS-level protocol handlers can't fight over the same - scheme. + scheme. The window's own origin is upstream's `t3code://app`, because T3 Connect needs it; that + origin never touches the OS, and each install keeps its own browser storage in its own data folder. - **Visibly different once open.** The app icon is an orange T3 tile with an "SI" corner badge, the same icon the sidebar header shows, the window title reads `T3 Code Fork (Alpha)`, and the sidebar wordmark carries a small `FORK` tag, so the taskbar, Alt-Tab and the open window all say which build you are in diff --git a/fork-features.json b/fork-features.json index b4427c091..fafee3e13 100644 --- a/fork-features.json +++ b/fork-features.json @@ -3,8 +3,8 @@ "features": [ { "name": "fork-app-identity", - "description": "The fork installs and runs beside official T3 Code: its own app id, product name, state directory (~/.t3-fork) and t3code-fork:// URL scheme. Issues #34 and #59.", - "keep": "Our app id, product name, window title, ~/.t3-fork state directory, t3code-fork:// URL scheme and Linux desktop entry name and WM class replace upstream's wherever these files set them; take upstream for the rest of these files, then re-apply those settings on top.", + "description": "The fork installs and runs beside official T3 Code: its own app id, product name, state directory (~/.t3-fork) and t3code-fork:// link scheme, the one the OS routes to the app. The renderer itself is served from upstream's t3code://app, see fork-connect-renderer-origin. Issues #34 and #59.", + "keep": "Our app id, product name, window title, ~/.t3-fork state directory, t3code-fork:// link scheme and Linux desktop entry name and WM class replace upstream's wherever these files set them; the renderer scheme stays upstream's t3code; take upstream for the rest of these files, then re-apply those settings on top.", "files": [ "apps/desktop/src/app/DesktopEnvironment.ts", "apps/desktop/src/app/DesktopEarlyElectronStartup.ts", @@ -131,6 +131,25 @@ "test": "scripts/lib/fork-public-env.test.ts", "patchesUpstream": true }, + { + "name": "fork-connect-renderer-origin", + "description": "T3 Connect sign-in works in the fork. T3's production Clerk instance accepts t3code://app and rejects t3code-fork://app with origin_invalid, so the renderer is served from upstream's t3code scheme while the Clerk bridge, the installer and the Linux desktop entry keep the fork's t3code-fork link scheme. On first launch ForkRendererStorageMigration.ts copies localStorage (drafts, prompt stash, themes, sidebar and second mate folds) from the old t3code-fork://app origin to t3code://app. ForkRendererStorageMigration.ts is the fork's own module and upstream has no file at that path, so its test cannot pass on plain upstream.", + "keep": "ForkRendererStorageMigration.ts is ours whole. getDesktopLinkScheme, the link schemes in the privileged scheme list and the comments on them survive in ElectronProtocol.ts; DesktopClerk.ts, DesktopLinuxUrlHandler.ts and DesktopPreReadyPlatform.ts pass getDesktopLinkScheme where upstream passes getDesktopScheme; the migration call before registerDesktopProtocol survives in DesktopApp.ts; the Google and GitHub sign-in note survives in the doc; everything else in those files takes upstream.", + "files": [ + "apps/desktop/src/app/ForkRendererStorageMigration.ts", + "apps/desktop/src/electron/ElectronProtocol.ts", + "apps/desktop/src/app/DesktopClerk.ts", + "apps/desktop/src/app/DesktopLinuxUrlHandler.ts", + "apps/desktop/src/app/DesktopPreReadyPlatform.ts", + "apps/desktop/src/app/DesktopApp.ts", + "docs/operations/fork-windows-build.md" + ], + "test": [ + "apps/desktop/src/app/ForkRendererStorageMigration.test.ts", + "apps/desktop/src/electron/ElectronProtocol.test.ts" + ], + "patchesUpstream": true + }, { "name": "fm-worker-thread-model", "description": "A read-only worker thread says which model and thinking level is behind it: a strip where the composer would sit, and badges on a full sidebar row; a compact row leaves them to the hover card so its title keeps room. ReadOnlyThreadModel.tsx and readOnlyThreadModel.logic.ts are the fork's own modules and upstream has no file at either path, so the test cannot pass on plain upstream. Issue #135.",