| 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,6 +19,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 19 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 19 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 20 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | 20 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; |
| 21 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 21 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 22 | import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; | ||
| 22 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; | 23 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 23 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; | 24 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; |
| 24 | import * as DesktopObservability from "./DesktopObservability.ts"; | 25 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| ... | @@ -179,6 +180,10 @@ const bootstrap = Effect.gen(function* () { | ... | @@ -179,6 +180,10 @@ const bootstrap = Effect.gen(function* () { |
| 179 | }); | 180 | }); |
| 180 | yield* installDesktopIpcHandlers(); | 181 | yield* installDesktopIpcHandlers(); |
| 181 | yield* logBootstrapInfo("bootstrap ipc handlers registered"); | 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 | yield* snapShot.initialize; | 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,6 +70,10 @@ import { |
| 70 | } from "./methods/snapShot.ts"; | 70 | } from "./methods/snapShot.ts"; |
| 71 | import * as PreviewIpc from "./methods/preview.ts"; | 71 | import * as PreviewIpc from "./methods/preview.ts"; |
| 72 | import * as AppActivationIpc from "./methods/appActivation.ts"; | 72 | import * as AppActivationIpc from "./methods/appActivation.ts"; |
| 73 | import { | ||
| 74 | completeLegacyLocalStorage, | ||
| 75 | takeLegacyLocalStorage, | ||
| 76 | } from "./methods/legacyLocalStorage.ts"; | ||
| 73 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; | 77 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; |
| 74 | 78 | ||
| 75 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { | 79 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| ... | @@ -80,6 +84,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -80,6 +84,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 80 | yield* ipc.handle(AppActivationIpc.setReady); | 84 | yield* ipc.handle(AppActivationIpc.setReady); |
| 81 | yield* ipc.handle(AppActivationIpc.complete); | 85 | yield* ipc.handle(AppActivationIpc.complete); |
| 82 | 86 | ||
| 87 | yield* ipc.handleSync(takeLegacyLocalStorage); | ||
| 88 | yield* ipc.handle(completeLegacyLocalStorage); | ||
| 83 | yield* ipc.handleSync(getAppBranding); | 89 | yield* ipc.handleSync(getAppBranding); |
| 84 | yield* ipc.handleSync(getSystemLocale); | 90 | yield* ipc.handleSync(getSystemLocale); |
| 85 | yield* ipc.handleSync(getWindowFullscreenState); | 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,3 +119,5 @@ export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input" |
| 119 | 119 | ||
| 120 | export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; | 120 | export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; |
| 121 | export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; | 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,6 +60,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; |
| 60 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; | 60 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 61 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | 61 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 62 | import * as DesktopState from "./app/DesktopState.ts"; | 62 | import * as DesktopState from "./app/DesktopState.ts"; |
| 63 | import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; | ||
| 63 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; | 64 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 64 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 65 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 65 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; | 66 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| ... | @@ -140,6 +141,7 @@ const desktopFoundationLayer = Layer.mergeAll( | ... | @@ -140,6 +141,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 140 | MacPermissions.layer, | 141 | MacPermissions.layer, |
| 141 | DesktopState.layer, | 142 | DesktopState.layer, |
| 142 | DesktopShutdown.layer, | 143 | DesktopShutdown.layer, |
| 144 | DesktopLegacyLocalStorage.layer, | ||
| 143 | DesktopAppSettings.layer, | 145 | DesktopAppSettings.layer, |
| 144 | DesktopClientSettings.layer, | 146 | DesktopClientSettings.layer, |
| 145 | DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), | 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,6 +10,7 @@ import { exposeClerkBridge } from "@clerk/electron/preload"; |
| 10 | import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; | 10 | import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; |
| 11 | 11 | ||
| 12 | import * as IpcChannels from "./ipc/channels.ts"; | 12 | import * as IpcChannels from "./ipc/channels.ts"; |
| 13 | import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; | ||
| 13 | 14 | ||
| 14 | const SNAP_SHOT_EVENT_TYPES = new Set([ | 15 | const SNAP_SHOT_EVENT_TYPES = new Set([ |
| 15 | "requested", | 16 | "requested", |
| ... | @@ -30,6 +31,18 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent { | ... | @@ -30,6 +31,18 @@ function isSnapShotEvent(value: unknown): value is DesktopSnapShotEvent { |
| 30 | 31 | ||
| 31 | exposeClerkBridge({ passkeys: true }); | 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 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. | 46 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. |
| 34 | const clientPlatform = process.platform; | 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,7 +10,9 @@ and changes in either version do not sync to the other. Settings, attachments, a |
| 10 | remain shared. | 10 | remain shared. |
| 11 | 11 | ||
| 12 | The V2 desktop app uses a separate browser profile, so browser cookies and caches do not carry | 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 | The migrated thread keeps its title, project, provider and model selection, permission and | 17 | The migrated thread keeps its title, project, provider and model selection, permission and |
| 16 | interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and | 18 | interaction modes, branch or worktree, archive state, settlement state, snooze and pin state, and |