1import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
4import * as Context from "effect/Context";
5import * as SecureRandom from "effect/crypto/SecureRandom";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import * as Ref from "effect/Ref";
13
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15
16const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);
17const decodeClientSettingsDocument = Schema.decodeEffect(
18 fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)),
19);
20const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema);
21const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) {
22 const document = yield* decodeClientSettingsDocument(raw);
23 // Select the shape before validation so invalid legacy settings cannot become defaults.
24 return yield* decodeClientSettingsValue(
25 Object.hasOwn(document, "settings") ? document.settings : document,
26 );
27});
28const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);
29
30export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopClientSettingsReadError>()(
31 "DesktopClientSettingsReadError",
32 {
33 operation: Schema.Literals(["read-file", "decode-document"]),
34 path: Schema.String,
35 cause: Schema.Defect(),
36 },
37) {
38 override get message(): string {
39 return `Desktop client settings read failed during ${this.operation} at ${this.path}.`;
40 }
41}
42
43const DesktopClientSettingsWriteOperation = Schema.Literals([
44 "create-temporary-file-name",
45 "resolve-symlink",
46 "encode-document",
47 "create-directory",
48 "write-temporary-file",
49 "replace-settings-file",
50]);
51
52export class DesktopClientSettingsWriteError extends Schema.TaggedError<DesktopClientSettingsWriteError>()(
53 "DesktopClientSettingsWriteError",
54 {
55 operation: DesktopClientSettingsWriteOperation,
56 path: Schema.String,
57 cause: Schema.Defect(),
58 },
59) {
60 override get message(): string {
61 return `Desktop client settings write failed during ${this.operation} at ${this.path}.`;
62 }
63}
64
65export class DesktopClientSettings extends Context.Service<
66 DesktopClientSettings,
67 {
68 readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>;
69 readonly set: (
70 settings: ClientSettings,
71 ) => Effect.Effect<void, DesktopClientSettingsWriteError>;
72 }
73>()("@t3tools/desktop/settings/DesktopClientSettings") {}
74
75const readClientSettings = (
76 fileSystem: FileSystem.FileSystem,
77 settingsPath: string,
78): Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError> =>
79 fileSystem.readFileString(settingsPath).pipe(
80 Effect.asSome,
81 Effect.catchTags({
82 PlatformError: (cause) =>
83 cause.reason._tag === "NotFound"
84 ? Effect.succeed(Option.none<string>())
85 : Effect.logWarning("Could not read desktop client settings.", cause).pipe(
86 Effect.annotateLogs({ settingsPath }),
87 Effect.andThen(
88 Effect.fail(
89 new DesktopClientSettingsReadError({
90 operation: "read-file",
91 path: settingsPath,
92 cause,
93 }),
94 ),
95 ),
96 ),
97 }),
98 Effect.flatMap(
99 Option.match({
100 onNone: () => Effect.succeed(Option.none<ClientSettings>()),
101 onSome: (raw) =>
102 decodeClientSettingsJson(raw).pipe(
103 Effect.asSome,
104 Effect.catchTags({
105 SchemaError: (cause) =>
106 Effect.logWarning("Could not decode desktop client settings.", cause).pipe(
107 Effect.annotateLogs({ settingsPath }),
108 Effect.andThen(
109 Effect.fail(
110 new DesktopClientSettingsReadError({
111 operation: "decode-document",
112 path: settingsPath,
113 cause,
114 }),
115 ),
116 ),
117 ),
118 }),
119 ),
120 }),
121 ),
122 );
123
124const writeClientSettings = Effect.fnUntraced(function* (input: {
125 readonly fileSystem: FileSystem.FileSystem;
126 readonly path: Path.Path;
127 readonly settingsPath: string;
128 readonly settings: ClientSettings;
129 readonly suffix: string;
130}): Effect.fn.Return<void, DesktopClientSettingsWriteError> {
131 const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
132 Effect.provideService(FileSystem.FileSystem, input.fileSystem),
133 Effect.provideService(Path.Path, input.path),
134 Effect.mapError(
135 (cause) =>
136 new DesktopClientSettingsWriteError({
137 operation: "resolve-symlink",
138 path: input.settingsPath,
139 cause,
140 }),
141 ),
142 );
143 const directory = input.path.dirname(targetPath);
144 const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
145 const encoded = yield* encodeClientSettingsJson(input.settings).pipe(
146 Effect.mapError(
147 (cause) =>
148 new DesktopClientSettingsWriteError({
149 operation: "encode-document",
150 path: input.settingsPath,
151 cause,
152 }),
153 ),
154 );
155 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
156 Effect.mapError(
157 (cause) =>
158 new DesktopClientSettingsWriteError({
159 operation: "create-directory",
160 path: directory,
161 cause,
162 }),
163 ),
164 );
165 yield* Effect.gen(function* () {
166 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
167 Effect.mapError(
168 (cause) =>
169 new DesktopClientSettingsWriteError({
170 operation: "write-temporary-file",
171 path: tempPath,
172 cause,
173 }),
174 ),
175 );
176 yield* input.fileSystem.rename(tempPath, targetPath).pipe(
177 Effect.mapError(
178 (cause) =>
179 new DesktopClientSettingsWriteError({
180 operation: "replace-settings-file",
181 path: input.settingsPath,
182 cause,
183 }),
184 ),
185 );
186 }).pipe(Effect.ensuring(input.fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore)));
187});
188
189export const make = Effect.gen(function* () {
190 const environment = yield* DesktopEnvironment.DesktopEnvironment;
191 const fileSystem = yield* FileSystem.FileSystem;
192 const path = yield* Path.Path;
193 const random = yield* SecureRandom.SecureRandom;
194
195 return DesktopClientSettings.of({
196 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
197 Effect.withSpan("desktop.clientSettings.get"),
198 ),
199 set: Effect.fn("desktop.clientSettings.set")(function (settings) {
200 return random.uuidV4().pipe(
201 Effect.map((uuid) => uuid.replace(/-/g, "")),
202 Effect.flatMap((suffix) =>
203 writeClientSettings({
204 fileSystem,
205 path,
206 settingsPath: environment.clientSettingsPath,
207 settings,
208 suffix,
209 }),
210 ),
211 );
212 }),
213 });
214});
215
216export const layer = Layer.effect(DesktopClientSettings, make);
217
218export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>
219 Layer.effect(
220 DesktopClientSettings,
221 Effect.gen(function* () {
222 const settingsRef = yield* Ref.make(initialSettings);
223 return DesktopClientSettings.of({
224 get: Ref.get(settingsRef),
225 set: Effect.fn("DesktopClientSettings.set")(function (settings) {
226 return Ref.set(settingsRef, Option.some(settings));
227 }),
228 });
229 }),
230 );