| 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); |