1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Ref from "effect/Ref";
8
9import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
10import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13/**
14 * Carries the renderer's localStorage (prompt stash, unsent drafts, layout,
15 * theme) over from the V1 desktop profile, which V2 replaced with its own
16 * `t3code-v2` profile. The V1 profile is only read, never opened by Chromium,
17 * so this works while V1 is still running.
18 *
19 * `load` runs before the window opens; the preload takes the items once,
20 * merges them into the new profile, and calls `complete`, which writes a
21 * marker so later launches skip the read. A failed read or merge, or a window
22 * that never completes, leaves no marker and retries next launch.
23 */
24export class DesktopLegacyLocalStorage extends Context.Service<
25 DesktopLegacyLocalStorage,
26 {
27 readonly load: (userDataPath: string) => Effect.Effect<void>;
28 readonly take: Effect.Effect<Option.Option<Readonly<Record<string, string>>>>;
29 readonly complete: Effect.Effect<void>;
30 }
31>()("@t3tools/desktop/app/DesktopLegacyLocalStorage") {}
32
33const MARKER_FILE_NAME = "v1-local-storage-imported";
34// V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise.
35const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"];
36
37const make = Effect.gen(function* () {
38 const environment = yield* DesktopEnvironment.DesktopEnvironment;
39 const fs = yield* FileSystem.FileSystem;
40 const path = yield* Path.Path;
41 const items = yield* Ref.make(Option.none<Readonly<Record<string, string>>>());
42 const markerPath = yield* Ref.make(Option.none<string>());
43
44 /** Newest file mtime in a directory; appending to a log leaves the directory's own mtime alone. */
45 const newestFileMtime = (directory: string) =>
46 Effect.gen(function* () {
47 let newest = 0;
48 for (const name of yield* fs.readDirectory(directory)) {
49 const info = yield* fs.stat(path.join(directory, name)).pipe(Effect.option);
50 if (Option.isNone(info)) continue;
51 const mtime = Option.match(info.value.mtime, {
52 onNone: () => 0,
53 onSome: (date) => date.getTime(),
54 });
55 newest = Math.max(newest, mtime);
56 }
57 return newest;
58 }).pipe(Effect.option);
59
60 /** The V1 profile whose Local Storage was written most recently, if any. */
61 const findV1LocalStorage = Effect.gen(function* () {
62 let newest: { readonly directory: string; readonly mtime: number } | null = null;
63 for (const name of V1_PROFILE_NAMES) {
64 const directory = path.join(environment.appDataDirectory, name, "Local Storage", "leveldb");
65 const mtime = yield* newestFileMtime(directory);
66 if (Option.isNone(mtime)) continue;
67 if (newest === null || mtime.value > newest.mtime) newest = { directory, mtime: mtime.value };
68 }
69 return newest?.directory ?? null;
70 });
71
72 const writeMarker = Effect.gen(function* () {
73 const marker = yield* Ref.get(markerPath);
74 if (Option.isNone(marker)) return;
75 yield* fs
76 .writeFileString(marker.value, "")
77 .pipe(
78 Effect.catch((error) =>
79 Effect.logWarning("Could not record the V1 Local Storage import", error),
80 ),
81 );
82 });
83
84 const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) {
85 // Development already shares its profile between versions.
86 if (environment.isDevelopment) return;
87 const marker = path.join(userDataPath, MARKER_FILE_NAME);
88 if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return;
89 yield* Ref.set(markerPath, Option.some(marker));
90 const directory = yield* findV1LocalStorage;
91 if (directory === null) return yield* writeMarker;
92 const origin = ElectronProtocol.getDesktopUrl(false).replace(/\/$/, "");
93 const read = yield* readChromiumLocalStorage(directory, origin).pipe(
94 Effect.provideService(FileSystem.FileSystem, fs),
95 Effect.provideService(Path.Path, path),
96 Effect.tapError((error) =>
97 Effect.logWarning("Could not read V1 Local Storage; will retry next launch", error),
98 ),
99 Effect.option,
100 );
101 if (Option.isNone(read)) return;
102 yield* Effect.logInfo("V1 Local Storage ready to import", {
103 directory,
104 keys: read.value.size,
105 });
106 yield* Ref.set(items, Option.some(Object.fromEntries(read.value)));
107 });
108
109 return DesktopLegacyLocalStorage.of({
110 load,
111 // Taking clears the items, so a reload before `complete` cannot merge twice.
112 take: Ref.getAndSet(items, Option.none()),
113 complete: writeMarker,
114 });
115});
116
117export const layer = Layer.effect(DesktopLegacyLocalStorage, make);