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 Crypto from "effect/Crypto";
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* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
166 Effect.mapError(
167 (cause) =>
168 new DesktopClientSettingsWriteError({
169 operation: "write-temporary-file",
170 path: tempPath,
171 cause,
172 }),
173 ),
174 );
175 yield* input.fileSystem.rename(tempPath, targetPath).pipe(
176 Effect.mapError(
177 (cause) =>
178 new DesktopClientSettingsWriteError({
179 operation: "replace-settings-file",
180 path: input.settingsPath,
181 cause,
182 }),
183 ),
184 );
185});
186
187export const make = Effect.gen(function* () {
188 const environment = yield* DesktopEnvironment.DesktopEnvironment;
189 const fileSystem = yield* FileSystem.FileSystem;
190 const path = yield* Path.Path;
191 const crypto = yield* Crypto.Crypto;
192
193 return DesktopClientSettings.of({
194 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
195 Effect.withSpan("desktop.clientSettings.get"),
196 ),
197 set: (settings) =>
198 crypto.randomUUIDv4().pipe(
199 Effect.map((uuid) => uuid.replace(/-/g, "")),
200 Effect.mapError(
201 (cause) =>
202 new DesktopClientSettingsWriteError({
203 operation: "create-temporary-file-name",
204 path: environment.clientSettingsPath,
205 cause,
206 }),
207 ),
208 Effect.flatMap((suffix) =>
209 writeClientSettings({
210 fileSystem,
211 path,
212 settingsPath: environment.clientSettingsPath,
213 settings,
214 suffix,
215 }),
216 ),
217 Effect.withSpan("desktop.clientSettings.set"),
218 ),
219 });
220});
221
222export const layer = Layer.effect(DesktopClientSettings, make);
223
224export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>
225 Layer.effect(
226 DesktopClientSettings,
227 Effect.gen(function* () {
228 const settingsRef = yield* Ref.make(initialSettings);
229 return DesktopClientSettings.of({
230 get: Ref.get(settingsRef),
231 set: (settings) => Ref.set(settingsRef, Option.some(settings)),
232 });
233 }),
234 );