Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopLinuxUrlHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopPreReadyPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down
198 changes: 198 additions & 0 deletions apps/desktop/src/app/ForkRendererStorageMigration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, string>>();
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)),
);
});
Loading
Loading