| author | |
| committer | |
| log | c40873762863abe34e2e784c518d81cbc08f6662 |
| tree | c9e09bb79ed0aa07c7249f8bfcf8e33627a73d99 |
| parent | 06e627448bc1ee58487c1a2a9d6bf8d5ef479af3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 19 | 19 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 20 | 20 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; |
| 21 | 21 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 22 | import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; | |
| 22 | 23 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 23 | 24 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; |
| 24 | 25 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| ... | ... | @@ -179,6 +180,10 @@ const bootstrap = Effect.gen(function* () { |
| 179 | 180 | }); |
| 180 | 181 | yield* installDesktopIpcHandlers(); |
| 181 | 182 | 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 | ); | |
| 182 | 187 | |
| 183 | 188 | yield* snapShot.initialize; |
| 184 | 189 |
apps/desktop/src/app/DesktopLegacyLocalStorage.ts created+117| ... | ... | @@ -0,0 +1,117 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Ref from "effect/Ref"; | |
| 8 | ||
| 9 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | |
| 10 | import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; | |
| 11 | import * 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 | */ | |
| 24 | export 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 | ||
| 33 | const MARKER_FILE_NAME = "v1-local-storage-imported"; | |
| 34 | // V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise. | |
| 35 | const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"]; | |
| 36 | ||
| 37 | const 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 | ||
| 117 | export const layer = Layer.effect(DesktopLegacyLocalStorage, make); |
apps/desktop/src/app/chromiumLocalStorage.ts created+460| ... | ... | @@ -0,0 +1,460 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import * as Path from "effect/Path"; | |
| 4 | import * as PlatformError from "effect/PlatformError"; | |
| 5 | import * 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 | ||
| 16 | const LOG_BLOCK_SIZE = 32 * 1024; | |
| 17 | const LOG_HEADER_SIZE = 7; | |
| 18 | const TABLE_FOOTER_SIZE = 48; | |
| 19 | const BLOCK_TRAILER_SIZE = 5; | |
| 20 | ||
| 21 | class LevelDbFormatError extends Schema.TaggedError<LevelDbFormatError>()("LevelDbFormatError", { | |
| 22 | detail: Schema.String, | |
| 23 | }) {} | |
| 24 | ||
| 25 | const isLevelDbFormatError = Schema.is(LevelDbFormatError); | |
| 26 | ||
| 27 | const 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. | |
| 32 | const textDecoderUtf16 = new TextDecoder("utf-16le", { ignoreBOM: true }); | |
| 33 | const textDecoderUtf8 = new TextDecoder(); | |
| 34 | ||
| 35 | /** True ISO-8859-1. WHATWG's "latin1" label is windows-1252, which remaps 0x80-0x9F. */ | |
| 36 | function 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 | ||
| 44 | class 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 | ||
| 80 | const readUint32 = (bytes: Uint8Array, offset: number) => | |
| 81 | (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)) + | |
| 82 | bytes[offset + 3]! * 0x1000000; | |
| 83 | ||
| 84 | const 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 | ||
| 94 | function 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. */ | |
| 101 | const unmaskCrc = (masked: number) => { | |
| 102 | const rotated = (masked - 0xa282ead8) >>> 0; | |
| 103 | return ((rotated >>> 17) | (rotated << 15)) >>> 0; | |
| 104 | }; | |
| 105 | ||
| 106 | function 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 | */ | |
| 157 | function 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 | ||
| 209 | interface 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. */ | |
| 216 | function 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 | ||
| 266 | interface VersionedEntry { | |
| 267 | readonly sequence: number; | |
| 268 | /** `null` marks a deletion. */ | |
| 269 | readonly value: Uint8Array | null; | |
| 270 | } | |
| 271 | ||
| 272 | type EntryMap = Map<string, { readonly key: Uint8Array; readonly entry: VersionedEntry }>; | |
| 273 | ||
| 274 | const keyId = decodeLatin1; | |
| 275 | ||
| 276 | function 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). */ | |
| 283 | function 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 | ||
| 302 | function 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 | ||
| 315 | function* 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. */ | |
| 336 | function 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). */ | |
| 359 | function 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 | ||
| 367 | export 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 | */ | |
| 383 | export 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 { |
| 70 | 70 | } from "./methods/snapShot.ts"; |
| 71 | 71 | import * as PreviewIpc from "./methods/preview.ts"; |
| 72 | 72 | import * as AppActivationIpc from "./methods/appActivation.ts"; |
| 73 | import { | |
| 74 | completeLegacyLocalStorage, | |
| 75 | takeLegacyLocalStorage, | |
| 76 | } from "./methods/legacyLocalStorage.ts"; | |
| 73 | 77 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; |
| 74 | 78 | |
| 75 | 79 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| ... | ... | @@ -80,6 +84,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 80 | 84 | yield* ipc.handle(AppActivationIpc.setReady); |
| 81 | 85 | yield* ipc.handle(AppActivationIpc.complete); |
| 82 | 86 | |
| 87 | yield* ipc.handleSync(takeLegacyLocalStorage); | |
| 88 | yield* ipc.handle(completeLegacyLocalStorage); | |
| 83 | 89 | yield* ipc.handleSync(getAppBranding); |
| 84 | 90 | yield* ipc.handleSync(getSystemLocale); |
| 85 | 91 | 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" |
| 119 | 119 | |
| 120 | 120 | export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; |
| 121 | 121 | export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; |
| 122 | export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; | |
| 123 | export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; |
apps/desktop/src/ipc/methods/legacyLocalStorage.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import * as Electron from "electron"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | ||
| 6 | import * as DesktopLegacyLocalStorage from "../../app/DesktopLegacyLocalStorage.ts"; | |
| 7 | import * as IpcChannels from "../channels.ts"; | |
| 8 | import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; | |
| 9 | ||
| 10 | export 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 | ||
| 19 | export 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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; | |
| 4 | ||
| 5 | const 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 | ||
| 14 | const stash = (...ids: string[]) => | |
| 15 | JSON.stringify({ | |
| 16 | version: 2, | |
| 17 | state: { entries: ids.map((id) => ({ id, prompt: `prompt ${id}` })) }, | |
| 18 | }); | |
| 19 | ||
| 20 | const 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 | ||
| 30 | describe("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 | ||
| 8 | interface MergeStorage { | |
| 9 | getItem(key: string): string | null; | |
| 10 | setItem(key: string, value: string): void; | |
| 11 | } | |
| 12 | ||
| 13 | const PROMPT_STASH_KEY = "t3code:prompt-stash:v2"; | |
| 14 | const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1"; | |
| 15 | const 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. */ | |
| 22 | const MAX_STASH_ENTRIES = 20; | |
| 23 | const DRAFT_RECORD_FIELDS = [ | |
| 24 | "draftsByThreadKey", | |
| 25 | "draftThreadsByThreadKey", | |
| 26 | "logicalProjectDraftThreadKeyByLogicalProjectKey", | |
| 27 | ] as const; | |
| 28 | ||
| 29 | type JsonObject = Record<string, unknown>; | |
| 30 | ||
| 31 | const isObject = (value: unknown): value is JsonObject => | |
| 32 | typeof value === "object" && value !== null && !Array.isArray(value); | |
| 33 | ||
| 34 | function 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. */ | |
| 44 | function 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. */ | |
| 59 | function 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 | ||
| 72 | const isMergedKey = (key: string) => key === PROMPT_STASH_KEY || key === COMPOSER_DRAFTS_KEY; | |
| 73 | ||
| 74 | function 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. */ | |
| 90 | export 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"; |
| 60 | 60 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 61 | 61 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 62 | 62 | import * as DesktopState from "./app/DesktopState.ts"; |
| 63 | import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; | |
| 63 | 64 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 64 | 65 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 65 | 66 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| ... | ... | @@ -140,6 +141,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 140 | 141 | MacPermissions.layer, |
| 141 | 142 | DesktopState.layer, |
| 142 | 143 | DesktopShutdown.layer, |
| 144 | DesktopLegacyLocalStorage.layer, | |
| 143 | 145 | DesktopAppSettings.layer, |
| 144 | 146 | DesktopClientSettings.layer, |
| 145 | 147 | DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), |
apps/desktop/src/preload.ts+13| ... | ... | @@ -10,6 +10,7 @@ import { exposeClerkBridge } from "@clerk/electron/preload"; |
| 10 | 10 | import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; |
| 11 | 11 | |
| 12 | 12 | import * as IpcChannels from "./ipc/channels.ts"; |
| 13 | import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; | |
| 13 | 14 | |
| 14 | 15 | const SNAP_SHOT_EVENT_TYPES = new Set([ |
| 15 | 16 | "requested", |
| ... | ... | @@ -30,6 +31,18 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent { |
| 30 | 31 | |
| 31 | 32 | exposeClerkBridge({ passkeys: true }); |
| 32 | 33 | |
| 34 | // Runs before any app script reads localStorage. See DesktopLegacyLocalStorage. | |
| 35 | try { | |
| 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 | ||
| 33 | 46 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. |
| 34 | 47 | const clientPlatform = process.platform; |
| 35 | 48 |
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 |
| 10 | 10 | remain shared. |
| 11 | 11 | |
| 12 | 12 | The V2 desktop app uses a separate browser profile, so browser cookies and caches do not carry |
| 13 | over from V1. You may need to sign in again to websites opened inside the app. | |
| 13 | over from V1. You may need to sign in again to websites opened inside the app. On its first launch, | |
| 14 | the V2 desktop app copies stashed prompts, unsent drafts, layout, and theme from V1. Anything you | |
| 15 | change in V2 afterwards stays in V2. | |
| 14 | 16 | |
| 15 | 17 | The migrated thread keeps its title, project, provider and model selection, permission and |
| 16 | 18 | interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and |