|
| 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); |
0 commit comments