authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-04 22:41:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 22:41:06-07:00
logc40873762863abe34e2e784c518d81cbc08f6662
treec9e09bb79ed0aa07c7249f8bfcf8e33627a73d99
parent06e627448bc1ee58487c1a2a9d6bf8d5ef479af3
signature Signed by PGP key B5690EEEBB952194

fix(desktop): V2 imports stashed prompts and drafts from the V1 profile (#15072)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 853 insertions(+), 1 deletions(-)

apps/desktop/src/app/DesktopApp.ts+5
......@@ -19,6 +19,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
1919import * as DesktopWindow from "../window/DesktopWindow.ts";
2020import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
2121import * as DesktopEnvironment from "./DesktopEnvironment.ts";
22import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
2223import * as DesktopLifecycle from "./DesktopLifecycle.ts";
2324import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
2425import * as DesktopObservability from "./DesktopObservability.ts";
......@@ -179,6 +180,10 @@ const bootstrap = Effect.gen(function* () {
179180 });
180181 yield* installDesktopIpcHandlers();
181182 yield* logBootstrapInfo("bootstrap ipc handlers registered");
183 // Before any window: the preload merges these items before the app reads storage.
184 yield* (yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage).load(
185 yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath,
186 );
182187
183188 yield* snapShot.initialize;
184189
apps/desktop/src/app/DesktopLegacyLocalStorage.ts created+117
......@@ -0,0 +1,117 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Ref from "effect/Ref";
8
9import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
10import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13/**
14 * Carries the renderer's localStorage (prompt stash, unsent drafts, layout,
15 * theme) over from the V1 desktop profile, which V2 replaced with its own
16 * `t3code-v2` profile. The V1 profile is only read, never opened by Chromium,
17 * so this works while V1 is still running.
18 *
19 * `load` runs before the window opens; the preload takes the items once,
20 * merges them into the new profile, and calls `complete`, which writes a
21 * marker so later launches skip the read. A failed read or merge, or a window
22 * that never completes, leaves no marker and retries next launch.
23 */
24export class DesktopLegacyLocalStorage extends Context.Service<
25 DesktopLegacyLocalStorage,
26 {
27 readonly load: (userDataPath: string) => Effect.Effect<void>;
28 readonly take: Effect.Effect<Option.Option<Readonly<Record<string, string>>>>;
29 readonly complete: Effect.Effect<void>;
30 }
31>()("@t3tools/desktop/app/DesktopLegacyLocalStorage") {}
32
33const MARKER_FILE_NAME = "v1-local-storage-imported";
34// V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise.
35const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"];
36
37const make = Effect.gen(function* () {
38 const environment = yield* DesktopEnvironment.DesktopEnvironment;
39 const fs = yield* FileSystem.FileSystem;
40 const path = yield* Path.Path;
41 const items = yield* Ref.make(Option.none<Readonly<Record<string, string>>>());
42 const markerPath = yield* Ref.make(Option.none<string>());
43
44 /** Newest file mtime in a directory; appending to a log leaves the directory's own mtime alone. */
45 const newestFileMtime = (directory: string) =>
46 Effect.gen(function* () {
47 let newest = 0;
48 for (const name of yield* fs.readDirectory(directory)) {
49 const info = yield* fs.stat(path.join(directory, name)).pipe(Effect.option);
50 if (Option.isNone(info)) continue;
51 const mtime = Option.match(info.value.mtime, {
52 onNone: () => 0,
53 onSome: (date) => date.getTime(),
54 });
55 newest = Math.max(newest, mtime);
56 }
57 return newest;
58 }).pipe(Effect.option);
59
60 /** The V1 profile whose Local Storage was written most recently, if any. */
61 const findV1LocalStorage = Effect.gen(function* () {
62 let newest: { readonly directory: string; readonly mtime: number } | null = null;
63 for (const name of V1_PROFILE_NAMES) {
64 const directory = path.join(environment.appDataDirectory, name, "Local Storage", "leveldb");
65 const mtime = yield* newestFileMtime(directory);
66 if (Option.isNone(mtime)) continue;
67 if (newest === null || mtime.value > newest.mtime) newest = { directory, mtime: mtime.value };
68 }
69 return newest?.directory ?? null;
70 });
71
72 const writeMarker = Effect.gen(function* () {
73 const marker = yield* Ref.get(markerPath);
74 if (Option.isNone(marker)) return;
75 yield* fs
76 .writeFileString(marker.value, "")
77 .pipe(
78 Effect.catch((error) =>
79 Effect.logWarning("Could not record the V1 Local Storage import", error),
80 ),
81 );
82 });
83
84 const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) {
85 // Development already shares its profile between versions.
86 if (environment.isDevelopment) return;
87 const marker = path.join(userDataPath, MARKER_FILE_NAME);
88 if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return;
89 yield* Ref.set(markerPath, Option.some(marker));
90 const directory = yield* findV1LocalStorage;
91 if (directory === null) return yield* writeMarker;
92 const origin = ElectronProtocol.getDesktopUrl(false).replace(/\/$/, "");
93 const read = yield* readChromiumLocalStorage(directory, origin).pipe(
94 Effect.provideService(FileSystem.FileSystem, fs),
95 Effect.provideService(Path.Path, path),
96 Effect.tapError((error) =>
97 Effect.logWarning("Could not read V1 Local Storage; will retry next launch", error),
98 ),
99 Effect.option,
100 );
101 if (Option.isNone(read)) return;
102 yield* Effect.logInfo("V1 Local Storage ready to import", {
103 directory,
104 keys: read.value.size,
105 });
106 yield* Ref.set(items, Option.some(Object.fromEntries(read.value)));
107 });
108
109 return DesktopLegacyLocalStorage.of({
110 load,
111 // Taking clears the items, so a reload before `complete` cannot merge twice.
112 take: Ref.getAndSet(items, Option.none()),
113 complete: writeMarker,
114 });
115});
116
117export const layer = Layer.effect(DesktopLegacyLocalStorage, make);
apps/desktop/src/app/chromiumLocalStorage.ts created+460
......@@ -0,0 +1,460 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as PlatformError from "effect/PlatformError";
5import * as Schema from "effect/Schema";
6
7/**
8 * Read-only decoder for a Chromium profile's `Local Storage/leveldb`.
9 *
10 * It reads the database files directly and never takes LevelDB's lock, so it
11 * works while another app version has the profile open. Live files come from
12 * the MANIFEST that CURRENT names, and the newest sequence number wins, which
13 * is how LevelDB itself resolves overwrites and deletions.
14 */
15
16const LOG_BLOCK_SIZE = 32 * 1024;
17const LOG_HEADER_SIZE = 7;
18const TABLE_FOOTER_SIZE = 48;
19const BLOCK_TRAILER_SIZE = 5;
20
21class LevelDbFormatError extends Schema.TaggedError<LevelDbFormatError>()("LevelDbFormatError", {
22 detail: Schema.String,
23}) {}
24
25const isLevelDbFormatError = Schema.is(LevelDbFormatError);
26
27const fail = (detail: string): never => {
28 throw new LevelDbFormatError({ detail });
29};
30
31// ignoreBOM keeps a leading U+FEFF that is part of the stored string.
32const textDecoderUtf16 = new TextDecoder("utf-16le", { ignoreBOM: true });
33const textDecoderUtf8 = new TextDecoder();
34
35/** True ISO-8859-1. WHATWG's "latin1" label is windows-1252, which remaps 0x80-0x9F. */
36function decodeLatin1(bytes: Uint8Array): string {
37 let result = "";
38 for (let offset = 0; offset < bytes.length; offset += 0x8000) {
39 result += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
40 }
41 return result;
42}
43
44class Reader {
45 readonly bytes: Uint8Array;
46 offset: number;
47 constructor(bytes: Uint8Array, offset = 0) {
48 this.bytes = bytes;
49 this.offset = offset;
50 }
51 get done() {
52 return this.offset >= this.bytes.length;
53 }
54 byte(): number {
55 if (this.offset >= this.bytes.length) fail("unexpected end of data");
56 return this.bytes[this.offset++]!;
57 }
58 varint(): number {
59 let result = 0;
60 let multiplier = 1;
61 for (let shift = 0; shift < 64; shift += 7) {
62 const byte = this.byte();
63 result += (byte & 0x7f) * multiplier;
64 if ((byte & 0x80) === 0) return result;
65 multiplier *= 128;
66 }
67 return fail("varint too long");
68 }
69 take(length: number): Uint8Array {
70 if (length < 0 || this.offset + length > this.bytes.length) fail("length out of range");
71 const slice = this.bytes.subarray(this.offset, this.offset + length);
72 this.offset += length;
73 return slice;
74 }
75 lengthPrefixed(): Uint8Array {
76 return this.take(this.varint());
77 }
78}
79
80const readUint32 = (bytes: Uint8Array, offset: number) =>
81 (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)) +
82 bytes[offset + 3]! * 0x1000000;
83
84const CRC32C_TABLE = (() => {
85 const table = new Uint32Array(256);
86 for (let index = 0; index < 256; index++) {
87 let crc = index;
88 for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0x82f63b78 : crc >>> 1;
89 table[index] = crc >>> 0;
90 }
91 return table;
92})();
93
94function crc32c(bytes: Uint8Array): number {
95 let crc = 0xffffffff;
96 for (const byte of bytes) crc = CRC32C_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
97 return (crc ^ 0xffffffff) >>> 0;
98}
99
100/** LevelDB stores CRCs masked so a CRC of data containing CRCs stays well distributed. */
101const unmaskCrc = (masked: number) => {
102 const rotated = (masked - 0xa282ead8) >>> 0;
103 return ((rotated >>> 17) | (rotated << 15)) >>> 0;
104};
105
106function decompressSnappy(input: Uint8Array): Uint8Array {
107 const reader = new Reader(input);
108 const output = new Uint8Array(reader.varint());
109 let written = 0;
110 while (!reader.done) {
111 const tag = reader.byte();
112 const kind = tag & 3;
113 if (kind === 0) {
114 let length = tag >>> 2;
115 if (length >= 60) {
116 const extraBytes = length - 59;
117 length = 0;
118 for (let index = 0; index < extraBytes; index++) length += reader.byte() * 256 ** index;
119 }
120 length += 1;
121 if (written + length > output.length) fail("snappy literal overflows output");
122 output.set(reader.take(length), written);
123 written += length;
124 continue;
125 }
126 let length: number;
127 let offset: number;
128 if (kind === 1) {
129 length = 4 + ((tag >>> 2) & 7);
130 offset = ((tag >>> 5) << 8) | reader.byte();
131 } else if (kind === 2) {
132 length = (tag >>> 2) + 1;
133 offset = reader.byte() | (reader.byte() << 8);
134 } else {
135 length = (tag >>> 2) + 1;
136 offset = readUint32(reader.take(4), 0);
137 }
138 if (offset === 0 || offset > written || written + length > output.length) {
139 fail("snappy copy out of range");
140 }
141 // Copies may overlap their own output, so they go byte by byte.
142 for (let index = 0; index < length; index++) {
143 output[written] = output[written - offset]!;
144 written++;
145 }
146 }
147 if (written !== output.length) fail("snappy output length mismatch");
148 return output;
149}
150
151/**
152 * Splits a LevelDB log (write-ahead log or MANIFEST) into its records. Like
153 * LevelDB's own reader, a corrupt record or zero padding skips the rest of its
154 * block, since a reused log keeps appending after a torn write, and a record
155 * cut off by the end of the file ends the log.
156 */
157function readLogRecords(bytes: Uint8Array): Uint8Array[] {
158 const records: Uint8Array[] = [];
159 let pending: Uint8Array[] | null = null;
160 let offset = 0;
161 while (offset + LOG_HEADER_SIZE <= bytes.length) {
162 const blockRemaining = LOG_BLOCK_SIZE - (offset % LOG_BLOCK_SIZE);
163 if (blockRemaining < LOG_HEADER_SIZE) {
164 offset += blockRemaining;
165 continue;
166 }
167 const length = bytes[offset + 4]! | (bytes[offset + 5]! << 8);
168 const type = bytes[offset + 6]!;
169 const end = offset + LOG_HEADER_SIZE + length;
170 const fitsBlock = LOG_HEADER_SIZE + length <= blockRemaining;
171 if (fitsBlock && end > bytes.length) break;
172 const typeAndPayload = bytes.subarray(offset + 6, end);
173 if (
174 type === 0 ||
175 !fitsBlock ||
176 crc32c(typeAndPayload) !== unmaskCrc(readUint32(bytes, offset))
177 ) {
178 offset += blockRemaining;
179 pending = null;
180 continue;
181 }
182 const payload = typeAndPayload.subarray(1);
183 offset = end;
184 if (type === 1) {
185 records.push(payload);
186 pending = null;
187 } else if (type === 2) {
188 pending = [payload];
189 } else if (type === 3 && pending) {
190 pending.push(payload);
191 } else if (type === 4 && pending) {
192 pending.push(payload);
193 const total = pending.reduce((sum, part) => sum + part.length, 0);
194 const record = new Uint8Array(total);
195 let position = 0;
196 for (const part of pending) {
197 record.set(part, position);
198 position += part.length;
199 }
200 records.push(record);
201 pending = null;
202 } else {
203 pending = null;
204 }
205 }
206 return records;
207}
208
209interface LiveFiles {
210 readonly tables: ReadonlyArray<number>;
211 readonly logNumber: number;
212 readonly previousLogNumber: number;
213}
214
215/** Replays MANIFEST version edits to find the live table files and log. */
216function readManifest(bytes: Uint8Array): LiveFiles {
217 const tables = new Set<number>();
218 let logNumber = 0;
219 let previousLogNumber = 0;
220 // LevelDB refuses to open a MANIFEST missing any of these, and so do we.
221 const required = new Set([2, 3, 4]);
222 for (const record of readLogRecords(bytes)) {
223 const reader = new Reader(record);
224 while (!reader.done) {
225 const tag = reader.varint();
226 required.delete(tag);
227 switch (tag) {
228 case 1: // comparator
229 reader.lengthPrefixed();
230 break;
231 case 2:
232 logNumber = reader.varint();
233 break;
234 case 3: // next file number
235 case 4: // last sequence
236 reader.varint();
237 break;
238 case 5: // compaction pointer
239 reader.varint();
240 reader.lengthPrefixed();
241 break;
242 case 6: // deleted file
243 reader.varint();
244 tables.delete(reader.varint());
245 break;
246 case 7: {
247 reader.varint();
248 tables.add(reader.varint());
249 reader.varint();
250 reader.lengthPrefixed();
251 reader.lengthPrefixed();
252 break;
253 }
254 case 9:
255 previousLogNumber = reader.varint();
256 break;
257 default:
258 fail(`unknown manifest tag ${tag}`);
259 }
260 }
261 }
262 if (required.size > 0) fail("incomplete manifest");
263 return { tables: [...tables], logNumber, previousLogNumber };
264}
265
266interface VersionedEntry {
267 readonly sequence: number;
268 /** `null` marks a deletion. */
269 readonly value: Uint8Array | null;
270}
271
272type EntryMap = Map<string, { readonly key: Uint8Array; readonly entry: VersionedEntry }>;
273
274const keyId = decodeLatin1;
275
276function record(entries: EntryMap, key: Uint8Array, entry: VersionedEntry) {
277 const id = keyId(key);
278 const existing = entries.get(id);
279 if (!existing || existing.entry.sequence < entry.sequence) entries.set(id, { key, entry });
280}
281
282/** Applies one write-ahead log record (a WriteBatch). */
283function readWriteBatch(entries: EntryMap, batch: Uint8Array) {
284 if (batch.length < 12) fail("write batch too short");
285 // The sequence is a little-endian uint64; real values stay far below 2^53.
286 const sequence = readUint32(batch, 0) + readUint32(batch, 4) * 0x100000000;
287 const count = readUint32(batch, 8);
288 const reader = new Reader(batch, 12);
289 for (let index = 0; index < count; index++) {
290 const type = reader.byte();
291 const key = reader.lengthPrefixed();
292 if (type === 1) {
293 record(entries, key, { sequence: sequence + index, value: reader.lengthPrefixed() });
294 } else if (type === 0) {
295 record(entries, key, { sequence: sequence + index, value: null });
296 } else {
297 fail(`unknown write batch record type ${type}`);
298 }
299 }
300}
301
302function readBlock(table: Uint8Array, reader: Reader): Uint8Array {
303 const offset = reader.varint();
304 const size = reader.varint();
305 if (offset + size + BLOCK_TRAILER_SIZE > table.length) fail("block handle out of range");
306 const contents = table.subarray(offset, offset + size);
307 const compression = table[offset + size]!;
308 const checksum = unmaskCrc(readUint32(table, offset + size + 1));
309 if (crc32c(table.subarray(offset, offset + size + 1)) !== checksum) fail("block checksum");
310 if (compression === 0) return contents;
311 if (compression === 1) return decompressSnappy(contents);
312 return fail(`unsupported block compression ${compression}`);
313}
314
315function* blockEntries(block: Uint8Array): Generator<[Uint8Array, Uint8Array]> {
316 if (block.length < 4) fail("block too short");
317 const restartCount = readUint32(block, block.length - 4);
318 const limit = block.length - 4 - restartCount * 4;
319 if (limit < 0) fail("restart array out of range");
320 const reader = new Reader(block.subarray(0, limit));
321 let key = new Uint8Array(0);
322 while (!reader.done) {
323 const shared = reader.varint();
324 const unshared = reader.varint();
325 const valueLength = reader.varint();
326 if (shared > key.length) fail("block key prefix out of range");
327 const nextKey = new Uint8Array(shared + unshared);
328 nextKey.set(key.subarray(0, shared));
329 nextKey.set(reader.take(unshared), shared);
330 key = nextKey;
331 yield [key, reader.take(valueLength)];
332 }
333}
334
335/** Applies every entry in one sorted table (`.ldb`/`.sst`) file. */
336function readTable(entries: EntryMap, table: Uint8Array) {
337 if (table.length < TABLE_FOOTER_SIZE) fail("table too short");
338 const footer = new Reader(table, table.length - TABLE_FOOTER_SIZE);
339 footer.varint();
340 footer.varint();
341 const index = readBlock(table, footer);
342 for (const [, handle] of blockEntries(index)) {
343 const block = readBlock(table, new Reader(handle));
344 for (const [internalKey, value] of blockEntries(block)) {
345 if (internalKey.length < 8) fail("internal key too short");
346 const trailerOffset = internalKey.length - 8;
347 const trailer =
348 readUint32(internalKey, trailerOffset) +
349 readUint32(internalKey, trailerOffset + 4) * 0x100000000;
350 const type = trailer % 256;
351 const sequence = Math.floor(trailer / 256);
352 const key = internalKey.subarray(0, trailerOffset);
353 record(entries, key, { sequence, value: type === 1 ? value : null });
354 }
355 }
356}
357
358/** Chromium prefixes each stored string with 0 (UTF-16LE) or 1 (Latin-1). */
359function decodeChromiumString(bytes: Uint8Array): string | null {
360 if (bytes.length === 0) return null;
361 const body = bytes.subarray(1);
362 if (bytes[0] === 1) return decodeLatin1(body);
363 if (bytes[0] === 0 && body.length % 2 === 0) return textDecoderUtf16.decode(body);
364 return null;
365}
366
367export class ChromiumLocalStorageReadError extends Schema.TaggedError<ChromiumLocalStorageReadError>()(
368 "ChromiumLocalStorageReadError",
369 {
370 directory: Schema.String,
371 cause: Schema.Defect(),
372 },
373) {
374 override get message() {
375 return `Could not read Chromium Local Storage at ${this.directory}.`;
376 }
377}
378
379/**
380 * Returns the localStorage items one origin (for example `t3code://app`) had
381 * in the profile, or an empty map when the profile never stored any.
382 */
383export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage.read")(function* (
384 directory: string,
385 origin: string,
386) {
387 const fs = yield* FileSystem.FileSystem;
388 const path = yield* Path.Path;
389 const readFile = (name: string) => fs.readFile(path.join(directory, name));
390 const wrap = Effect.mapError(
391 (cause: PlatformError.PlatformError | LevelDbFormatError) =>
392 new ChromiumLocalStorageReadError({ directory, cause }),
393 );
394 const decode = <A>(run: () => A) =>
395 Effect.try({
396 try: run,
397 catch: (cause) =>
398 isLevelDbFormatError(cause) ? cause : new LevelDbFormatError({ detail: String(cause) }),
399 });
400
401 const current = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim();
402 if (!/^MANIFEST-\d+$/.test(current)) {
403 return yield* new ChromiumLocalStorageReadError({
404 directory,
405 cause: new LevelDbFormatError({ detail: "bad CURRENT" }),
406 });
407 }
408 const manifest = yield* readFile(current).pipe(wrap);
409 const live = yield* decode(() => readManifest(manifest)).pipe(wrap);
410 const logs = (yield* fs.readDirectory(directory).pipe(wrap))
411 .map((name) => /^(\d+)\.log$/.exec(name))
412 // The logs LevelDB's own recovery replays.
413 .filter((match) => {
414 const number = Number(match?.[1]);
415 return number >= live.logNumber || number === live.previousLogNumber;
416 })
417 .map((match) => match![0]);
418
419 const entries: EntryMap = new Map();
420 for (const number of live.tables) {
421 const name = String(number).padStart(6, "0");
422 // Newer LevelDB names tables .ldb; older databases may still hold .sst files.
423 const bytes = yield* readFile(`${name}.ldb`).pipe(
424 Effect.catchIf(
425 (error) => error.reason._tag === "NotFound",
426 () => readFile(`${name}.sst`),
427 ),
428 wrap,
429 );
430 yield* decode(() => readTable(entries, bytes)).pipe(wrap);
431 }
432 for (const name of logs) {
433 const bytes = yield* readFile(name).pipe(wrap);
434 yield* decode(() => {
435 for (const batch of readLogRecords(bytes)) readWriteBatch(entries, batch);
436 }).pipe(wrap);
437 }
438
439 // A flush while another process has the database open can move entries
440 // from a log we read into a table our MANIFEST copy predates. Fail so the
441 // caller retries rather than import a snapshot missing those entries.
442 const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim();
443 const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap);
444 if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) {
445 return yield* new ChromiumLocalStorageReadError({
446 directory,
447 cause: new LevelDbFormatError({ detail: "database changed while reading" }),
448 });
449 }
450
451 const prefix = `_${origin}\u0000`;
452 const items = new Map<string, string>();
453 for (const [id, { key: rawKey, entry }] of entries) {
454 if (!id.startsWith(prefix) || entry.value === null) continue;
455 const key = decodeChromiumString(rawKey.subarray(prefix.length));
456 const value = decodeChromiumString(entry.value);
457 if (key !== null && value !== null) items.set(key, value);
458 }
459 return items;
460});
apps/desktop/src/ipc/DesktopIpcHandlers.ts+6
......@@ -70,6 +70,10 @@ import {
7070} from "./methods/snapShot.ts";
7171import * as PreviewIpc from "./methods/preview.ts";
7272import * as AppActivationIpc from "./methods/appActivation.ts";
73import {
74 completeLegacyLocalStorage,
75 takeLegacyLocalStorage,
76} from "./methods/legacyLocalStorage.ts";
7377import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
7478
7579export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
......@@ -80,6 +84,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8084 yield* ipc.handle(AppActivationIpc.setReady);
8185 yield* ipc.handle(AppActivationIpc.complete);
8286
87 yield* ipc.handleSync(takeLegacyLocalStorage);
88 yield* ipc.handle(completeLegacyLocalStorage);
8389 yield* ipc.handleSync(getAppBranding);
8490 yield* ipc.handleSync(getSystemLocale);
8591 yield* ipc.handleSync(getWindowFullscreenState);
apps/desktop/src/ipc/channels.ts+2
......@@ -119,3 +119,5 @@ export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"
119119
120120export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback";
121121export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback";
122export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage";
123export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage";
apps/desktop/src/ipc/methods/legacyLocalStorage.ts created+30
......@@ -0,0 +1,30 @@
1import * as Electron from "electron";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6import * as DesktopLegacyLocalStorage from "../../app/DesktopLegacyLocalStorage.ts";
7import * as IpcChannels from "../channels.ts";
8import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts";
9
10export const takeLegacyLocalStorage = makeSyncIpcMethod({
11 channel: IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL,
12 result: Schema.NullOr(Schema.Record(Schema.String, Schema.String)),
13 handler: Effect.fn("desktop.ipc.legacyLocalStorage.take")(function* () {
14 const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage;
15 return Option.getOrNull(yield* legacy.take);
16 }),
17});
18
19export const completeLegacyLocalStorage = makeIpcMethod({
20 channel: IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL,
21 payload: Schema.Void,
22 result: Schema.Void,
23 handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) {
24 const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage;
25 // Chromium writes localStorage to disk lazily; persist the merge before
26 // the marker stops later launches from retrying it.
27 if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData();
28 yield* legacy.complete;
29 }),
30});
apps/desktop/src/legacyLocalStorageMerge.test.ts created+102
......@@ -0,0 +1,102 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts";
4
5const memoryStorage = (initial: Record<string, string> = {}) => {
6 const items = new Map(Object.entries(initial));
7 return {
8 items,
9 getItem: (key: string) => items.get(key) ?? null,
10 setItem: (key: string, value: string) => void items.set(key, value),
11 };
12};
13
14const stash = (...ids: string[]) =>
15 JSON.stringify({
16 version: 2,
17 state: { entries: ids.map((id) => ({ id, prompt: `prompt ${id}` })) },
18 });
19
20const drafts = (version: number, threadKey: string, prompt: string) =>
21 JSON.stringify({
22 version,
23 state: {
24 draftsByThreadKey: { [threadKey]: { prompt } },
25 draftThreadsByThreadKey: {},
26 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
27 },
28 });
29
30describe("mergeLegacyLocalStorage", () => {
31 it("copies V1 keys into an empty profile, except the per-install client id", () => {
32 const storage = memoryStorage();
33 mergeLegacyLocalStorage(storage, {
34 "t3code:theme": "dark",
35 "t3code:prompt-stash:v2": stash("a"),
36 "t3.backgroundActivity.clientId": "v1-client",
37 });
38 assert.equal(storage.getItem("t3code:theme"), "dark");
39 assert.equal(storage.getItem("t3code:prompt-stash:v2"), stash("a"));
40 assert.equal(storage.getItem("t3.backgroundActivity.clientId"), null);
41 });
42
43 it("keeps V2 values for plain keys", () => {
44 const storage = memoryStorage({ "t3code:theme": "light" });
45 mergeLegacyLocalStorage(storage, { "t3code:theme": "dark" });
46 assert.equal(storage.getItem("t3code:theme"), "light");
47 });
48
49 it("appends V1 stash entries after V2's without duplicating ids", () => {
50 const storage = memoryStorage({ "t3code:prompt-stash:v2": stash("new", "shared") });
51 mergeLegacyLocalStorage(storage, { "t3code:prompt-stash:v2": stash("shared", "old") });
52 const ids = JSON.parse(storage.getItem("t3code:prompt-stash:v2")!).state.entries.map(
53 (entry: { id: string }) => entry.id,
54 );
55 assert.deepStrictEqual(ids, ["new", "shared", "old"]);
56 });
57
58 it("adds V1 drafts for other threads and keeps V2's draft for the same thread", () => {
59 const storage = memoryStorage({
60 "t3code:composer-drafts:v1": drafts(9, "env:shared", "v2 text"),
61 });
62 const legacy = JSON.parse(drafts(9, "env:shared", "v1 text"));
63 legacy.state.draftsByThreadKey["env:old"] = { prompt: "v1 only" };
64 mergeLegacyLocalStorage(storage, { "t3code:composer-drafts:v1": JSON.stringify(legacy) });
65 const merged = JSON.parse(storage.getItem("t3code:composer-drafts:v1")!);
66 assert.equal(merged.state.draftsByThreadKey["env:shared"].prompt, "v2 text");
67 assert.equal(merged.state.draftsByThreadKey["env:old"].prompt, "v1 only");
68 });
69
70 it("leaves V2 drafts alone when the stored shapes differ in version", () => {
71 const current = drafts(10, "env:a", "v2");
72 const storage = memoryStorage({ "t3code:composer-drafts:v1": current });
73 mergeLegacyLocalStorage(storage, { "t3code:composer-drafts:v1": drafts(9, "env:b", "v1") });
74 assert.equal(storage.getItem("t3code:composer-drafts:v1"), current);
75 });
76
77 it("caps the merged stash at the store's entry limit, dropping the oldest V1 entries", () => {
78 const v2Ids = Array.from({ length: 15 }, (_, index) => `v2-${index}`);
79 const v1Ids = Array.from({ length: 10 }, (_, index) => `v1-${index}`);
80 const storage = memoryStorage({ "t3code:prompt-stash:v2": stash(...v2Ids) });
81 mergeLegacyLocalStorage(storage, { "t3code:prompt-stash:v2": stash(...v1Ids) });
82 const ids = JSON.parse(storage.getItem("t3code:prompt-stash:v2")!).state.entries.map(
83 (entry: { id: string }) => entry.id,
84 );
85 assert.deepStrictEqual(ids, [...v2Ids, ...v1Ids.slice(0, 5)]);
86 });
87
88 it("keeps importing other keys after one write fails, and reports the failure", () => {
89 const storage = memoryStorage();
90 const setItem = storage.setItem;
91 storage.setItem = (key, value) => {
92 if (key === "t3code:theme") throw new DOMException("full", "QuotaExceededError");
93 setItem(key, value);
94 };
95 const complete = mergeLegacyLocalStorage(storage, {
96 "t3code:theme": "dark",
97 "t3code:last-editor": "zed",
98 });
99 assert.equal(storage.getItem("t3code:last-editor"), "zed");
100 assert.isFalse(complete);
101 });
102});
apps/desktop/src/legacyLocalStorageMerge.ts created+113
......@@ -0,0 +1,113 @@
1/**
2 * Merges V1 profile localStorage items into the V2 profile. Runs in the preload
3 * before the app reads storage, and never overwrites what V2 already holds: a
4 * key V2 has written keeps V2's value, except that stashed prompts and unsent
5 * drafts are merged entry by entry so neither side's are lost.
6 */
7
8interface MergeStorage {
9 getItem(key: string): string | null;
10 setItem(key: string, value: string): void;
11}
12
13const PROMPT_STASH_KEY = "t3code:prompt-stash:v2";
14const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1";
15const NOT_IMPORTED_KEYS = new Set([
16 // Identifies one running install to the server; two apps must not share it.
17 "t3.backgroundActivity.clientId",
18 // Resumes a permission setup V1 left mid-flow; V2 would redirect to it on boot.
19 "t3code:snap-shot-setup-resume:v1",
20]);
21/** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */
22const MAX_STASH_ENTRIES = 20;
23const DRAFT_RECORD_FIELDS = [
24 "draftsByThreadKey",
25 "draftThreadsByThreadKey",
26 "logicalProjectDraftThreadKeyByLogicalProjectKey",
27] as const;
28
29type JsonObject = Record<string, unknown>;
30
31const isObject = (value: unknown): value is JsonObject =>
32 typeof value === "object" && value !== null && !Array.isArray(value);
33
34function parseObject(raw: string): JsonObject | null {
35 try {
36 const parsed: unknown = JSON.parse(raw);
37 return isObject(parsed) ? parsed : null;
38 } catch {
39 return null;
40 }
41}
42
43/** V2 entries first, then V1 entries V2 does not already have, up to the stash cap. */
44function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null {
45 const currentEntries = isObject(current.state) ? current.state.entries : null;
46 const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null;
47 if (!Array.isArray(currentEntries) || !Array.isArray(legacyEntries)) return null;
48 if (current.version !== legacy.version) return null;
49 const ids = new Set(currentEntries.map((entry) => (isObject(entry) ? entry.id : undefined)));
50 const added = legacyEntries.filter((entry) => isObject(entry) && !ids.has(entry.id));
51 const state = isObject(current.state) ? current.state : {};
52 return {
53 ...current,
54 state: { ...state, entries: [...currentEntries, ...added].slice(0, MAX_STASH_ENTRIES) },
55 };
56}
57
58/** Adds V1 drafts for threads V2 has no draft for. Shapes must share a version. */
59function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null {
60 if (current.version !== legacy.version) return null;
61 if (!isObject(current.state) || !isObject(legacy.state)) return null;
62 const state: JsonObject = { ...current.state };
63 for (const field of DRAFT_RECORD_FIELDS) {
64 const currentRecord = state[field];
65 const legacyRecord = legacy.state[field];
66 if (!isObject(currentRecord) || !isObject(legacyRecord)) return null;
67 state[field] = { ...legacyRecord, ...currentRecord };
68 }
69 return { ...current, state };
70}
71
72const isMergedKey = (key: string) => key === PROMPT_STASH_KEY || key === COMPOSER_DRAFTS_KEY;
73
74function mergeValue(key: string, current: string, legacy: string): string | null {
75 const merge =
76 key === PROMPT_STASH_KEY
77 ? mergePromptStash
78 : key === COMPOSER_DRAFTS_KEY
79 ? mergeComposerDrafts
80 : null;
81 if (merge === null) return null;
82 const currentObject = parseObject(current);
83 const legacyObject = parseObject(legacy);
84 if (currentObject === null || legacyObject === null) return null;
85 const merged = merge(currentObject, legacyObject);
86 return merged === null ? null : JSON.stringify(merged);
87}
88
89/** Returns false when a write failed, so the caller can retry on a later launch. */
90export function mergeLegacyLocalStorage(
91 storage: MergeStorage,
92 legacyItems: Readonly<Record<string, string>>,
93): boolean {
94 let complete = true;
95 // Stash and drafts first, so a full quota costs layout state rather than prompts.
96 const keys = Object.keys(legacyItems).sort(
97 (left, right) => Number(isMergedKey(right)) - Number(isMergedKey(left)),
98 );
99 for (const key of keys) {
100 if (NOT_IMPORTED_KEYS.has(key)) continue;
101 const legacy = legacyItems[key]!;
102 const current = storage.getItem(key);
103 const next = current === null ? legacy : mergeValue(key, current, legacy);
104 if (next === null || next === current) continue;
105 try {
106 storage.setItem(key, next);
107 } catch {
108 // Over quota: skip this key rather than abandon the rest.
109 complete = false;
110 }
111 }
112 return complete;
113}
apps/desktop/src/main.ts+2
......@@ -60,6 +60,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
6060import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
6161import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
6262import * as DesktopState from "./app/DesktopState.ts";
63import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts";
6364import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
6465import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
6566import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
......@@ -140,6 +141,7 @@ const desktopFoundationLayer = Layer.mergeAll(
140141 MacPermissions.layer,
141142 DesktopState.layer,
142143 DesktopShutdown.layer,
144 DesktopLegacyLocalStorage.layer,
143145 DesktopAppSettings.layer,
144146 DesktopClientSettings.layer,
145147 DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)),
apps/desktop/src/preload.ts+13
......@@ -10,6 +10,7 @@ import { exposeClerkBridge } from "@clerk/electron/preload";
1010import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron";
1111
1212import * as IpcChannels from "./ipc/channels.ts";
13import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts";
1314
1415const SNAP_SHOT_EVENT_TYPES = new Set([
1516 "requested",
......@@ -30,6 +31,18 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent {
3031
3132exposeClerkBridge({ passkeys: true });
3233
34// Runs before any app script reads localStorage. See DesktopLegacyLocalStorage.
35try {
36 const legacyItems: unknown = ipcRenderer.sendSync(IpcChannels.TAKE_LEGACY_LOCAL_STORAGE_CHANNEL);
37 if (typeof legacyItems === "object" && legacyItems !== null) {
38 if (mergeLegacyLocalStorage(window.localStorage, legacyItems as Record<string, string>)) {
39 void ipcRenderer.invoke(IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL);
40 }
41 }
42} catch {
43 // Best effort: the app still starts on the V2 profile's own storage.
44}
45
3346// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
3447const clientPlatform = process.platform;
3548
docs/user/thread-migration.md+3-1
......@@ -10,7 +10,9 @@ and changes in either version do not sync to the other. Settings, attachments, a
1010remain shared.
1111
1212The V2 desktop app uses a separate browser profile, so browser cookies and caches do not carry
13over from V1. You may need to sign in again to websites opened inside the app.
13over from V1. You may need to sign in again to websites opened inside the app. On its first launch,
14the V2 desktop app copies stashed prompts, unsent drafts, layout, and theme from V1. Anything you
15change in V2 afterwards stays in V2.
1416
1517The migrated thread keeps its title, project, provider and model selection, permission and
1618interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and