authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 22:59:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 22:59:43-07:00
log65385c028169da7e18d0f25124f0e3f6a1208567
treeb0484a6c56a5e731f553a77ce4d88d47d6fb2503
parent6d2dae0627897e84e79bc7d2f164a564357fe9b0
signature Signed by PGP key B5690EEEBB952194

[codex] Refactor desktop settings Effect services (#3188)

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 994 insertions(+), 473 deletions(-)

apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts+112-5
......@@ -21,7 +21,6 @@ const textEncoder = new TextEncoder();
2121const decodeConnectionCatalog = Schema.decodeEffect(
2222 Schema.fromJsonString(ConnectionCatalogDocument),
2323);
24
2524function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {
2625 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
2726 isEncryptionAvailable: Effect.succeed(available),
......@@ -40,7 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean>
4039 return decoded.slice("encrypted:".length);
4140 });
4241 },
43 } satisfies ElectronSafeStorage.ElectronSafeStorageShape);
42 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
4443}
4544
4645function makeLayer(
......@@ -236,8 +235,11 @@ describe("DesktopConnectionCatalogStore", () => {
236235 const error = yield* store.get.pipe(Effect.flip);
237236 assert.instanceOf(
238237 error,
239 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError,
238 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError,
240239 );
240 assert.equal(error.operation, "decode-catalog-document");
241 assert.equal(error.catalogPath, catalogPath);
242 assert.exists(error.cause);
241243 assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json");
242244 }),
243245 ),
......@@ -253,7 +255,7 @@ describe("DesktopConnectionCatalogStore", () => {
253255 _tag: "PermissionDenied",
254256 module: "FileSystem",
255257 method: "readFileString",
256 pathOrDescriptor: `${baseDir}/connection-catalog.json`,
258 pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`,
257259 });
258260 const fileSystemLayer = Layer.succeed(
259261 FileSystem.FileSystem,
......@@ -270,10 +272,115 @@ describe("DesktopConnectionCatalogStore", () => {
270272 error,
271273 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError,
272274 );
273 assert.equal(error.cause, permissionError);
275 assert.equal(error.operation, "read-catalog");
276 assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`);
277 assert.strictEqual(error.cause, permissionError);
278 assert.equal(
279 error.message,
280 `Failed to read the desktop connection catalog at ${baseDir}/userdata/connection-catalog.json.`,
281 );
282 assert.notEqual(error.message, permissionError.message);
274283 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
275284 );
276285
286 it.effect("reports the failed catalog write operation and path", () =>
287 Effect.gen(function* () {
288 const baseFileSystem = yield* FileSystem.FileSystem;
289 const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
290 prefix: "t3-desktop-connection-catalog-test-",
291 });
292 const permissionError = PlatformError.systemError({
293 _tag: "PermissionDenied",
294 module: "FileSystem",
295 method: "makeDirectory",
296 pathOrDescriptor: `${baseDir}/userdata`,
297 });
298 const fileSystemLayer = Layer.succeed(
299 FileSystem.FileSystem,
300 FileSystem.makeNoop({
301 makeDirectory: () => Effect.fail(permissionError),
302 }),
303 );
304 const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe(
305 Effect.provide(makeLayer(baseDir, true, null, fileSystemLayer)),
306 );
307
308 const error = yield* store.set("{}").pipe(Effect.flip);
309 assert.instanceOf(
310 error,
311 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError,
312 );
313 assert.equal(error.operation, "create-directory");
314 assert.equal(error.path, `${baseDir}/userdata`);
315 assert.strictEqual(error.cause, permissionError);
316 assert.equal(
317 error.message,
318 `Desktop connection catalog write failed during create-directory at ${baseDir}/userdata.`,
319 );
320 assert.notEqual(error.message, permissionError.message);
321 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
322 );
323
324 it.effect("reports the legacy migration stage", () =>
325 withStore(
326 Effect.gen(function* () {
327 const environment = yield* DesktopEnvironment.DesktopEnvironment;
328 const fileSystem = yield* FileSystem.FileSystem;
329 const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
330 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
331 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json");
332
333 const error = yield* store.get.pipe(Effect.flip);
334 assert.instanceOf(
335 error,
336 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError,
337 );
338 assert.equal(error.operation, "read-legacy-registry");
339 assert.equal(error.catalogPath, `${environment.stateDir}/connection-catalog.json`);
340 assert.instanceOf(
341 error.cause,
342 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
343 );
344 const registryError =
345 error.cause as DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError;
346 assert.exists(registryError.cause);
347 assert.equal(
348 error.message,
349 `Legacy desktop saved-environment migration failed during read-legacy-registry into ${environment.stateDir}/connection-catalog.json.`,
350 );
351 assert.notEqual(error.message, registryError.message);
352 }),
353 ),
354 );
355
356 it.effect("reports invalid encrypted catalog data without exposing it", () =>
357 withStore(
358 Effect.gen(function* () {
359 const environment = yield* DesktopEnvironment.DesktopEnvironment;
360 const fileSystem = yield* FileSystem.FileSystem;
361 const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
362 const catalogPath = `${environment.stateDir}/connection-catalog.json`;
363 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
364 yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n');
365
366 const error = yield* store.get.pipe(Effect.flip);
367 assert.instanceOf(
368 error,
369 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError,
370 );
371 assert.equal(error.operation, "decode-encrypted-catalog");
372 assert.equal(error.resource, "encryptedCatalog");
373 assert.equal(error.catalogPath, catalogPath);
374 assert.exists(error.cause);
375 assert.equal(
376 error.message,
377 `Failed to decode encryptedCatalog for the desktop connection catalog at ${catalogPath}.`,
378 );
379 assert.notInclude(error.message, "%%%");
380 }),
381 ),
382 );
383
277384 it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () =>
278385 Effect.gen(function* () {
279386 const fileSystem = yield* FileSystem.FileSystem;
apps/desktop/src/app/DesktopConnectionCatalogStore.ts+242-126
......@@ -14,14 +14,12 @@ import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
1414import { fromLenientJson } from "@t3tools/shared/schemaJson";
1515import * as Context from "effect/Context";
1616import * as Crypto from "effect/Crypto";
17import * as Data from "effect/Data";
1817import * as Effect from "effect/Effect";
1918import * as Encoding from "effect/Encoding";
2019import * as FileSystem from "effect/FileSystem";
2120import * as Layer from "effect/Layer";
2221import * as Option from "effect/Option";
2322import * as Path from "effect/Path";
24import * as PlatformError from "effect/PlatformError";
2523import * as Schema from "effect/Schema";
2624
2725import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
......@@ -48,76 +46,156 @@ const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect(
4846 RuntimeConnectionCatalogDocumentJson,
4947);
5048
51export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError(
49const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([
50 "create-temporary-file-name",
51 "encode-document",
52 "create-directory",
53 "write-temporary-file",
54 "replace-catalog-file",
55]);
56type DesktopConnectionCatalogStoreWriteOperation =
57 typeof DesktopConnectionCatalogStoreWriteOperation.Type;
58
59const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
60 "read-legacy-registry",
61 "read-legacy-secret",
62 "encode-catalog",
63 "persist-catalog",
64]);
65type DesktopConnectionCatalogStoreMigrationOperation =
66 typeof DesktopConnectionCatalogStoreMigrationOperation.Type;
67
68export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreWriteError>()(
5269 "DesktopConnectionCatalogStoreWriteError",
53)<{
54 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
55}> {
56 override get message() {
57 return `Failed to write desktop connection catalog: ${this.cause.message}`;
70 {
71 operation: DesktopConnectionCatalogStoreWriteOperation,
72 path: Schema.String,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`;
5878 }
5979}
6080
61export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError(
81const writeError = (
82 operation: DesktopConnectionCatalogStoreWriteOperation,
83 path: string,
84 cause: unknown,
85): DesktopConnectionCatalogStoreWriteError =>
86 new DesktopConnectionCatalogStoreWriteError({
87 operation,
88 path,
89 cause,
90 });
91
92export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreDecodeError>()(
6293 "DesktopConnectionCatalogStoreDecodeError",
63)<{
64 readonly cause: Encoding.EncodingError;
65}> {
66 override get message() {
67 return "Failed to decode the desktop connection catalog.";
94 {
95 operation: Schema.Literal("decode-encrypted-catalog"),
96 resource: Schema.Literal("encryptedCatalog"),
97 catalogPath: Schema.String,
98 cause: Schema.Defect(),
99 },
100) {
101 override get message(): string {
102 return `Failed to decode ${this.resource} for the desktop connection catalog at ${this.catalogPath}.`;
68103 }
69104}
70105
71export class DesktopConnectionCatalogStoreReadError extends Data.TaggedError(
106export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreReadError>()(
72107 "DesktopConnectionCatalogStoreReadError",
73)<{
74 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
75}> {
76 override get message() {
77 return `Failed to read desktop connection catalog: ${this.cause.message}`;
108 {
109 operation: Schema.Literal("read-catalog"),
110 catalogPath: Schema.String,
111 cause: Schema.Defect(),
112 },
113) {
114 override get message(): string {
115 return `Failed to read the desktop connection catalog at ${this.catalogPath}.`;
78116 }
79117}
80118
81export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError(
82 "DesktopConnectionCatalogStoreMigrationError",
83)<{
84 readonly cause: unknown;
85}> {
86 override get message() {
87 return "Failed to migrate legacy desktop saved environments.";
119export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreDocumentDecodeError>()(
120 "DesktopConnectionCatalogStoreDocumentDecodeError",
121 {
122 operation: Schema.Literal("decode-catalog-document"),
123 catalogPath: Schema.String,
124 cause: Schema.Defect(),
125 },
126) {
127 override get message(): string {
128 return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`;
88129 }
89130}
90131
91export interface DesktopConnectionCatalogStoreShape {
92 readonly get: Effect.Effect<
93 Option.Option<string>,
94 | DesktopConnectionCatalogStoreReadError
95 | DesktopConnectionCatalogStoreDecodeError
96 | DesktopConnectionCatalogStoreMigrationError
97 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
98 | ElectronSafeStorage.ElectronSafeStorageDecryptError
99 >;
100 readonly set: (
101 catalog: string,
102 ) => Effect.Effect<
103 boolean,
104 | DesktopConnectionCatalogStoreWriteError
105 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
106 | ElectronSafeStorage.ElectronSafeStorageEncryptError
107 >;
108 readonly clear: Effect.Effect<void>;
132export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreMigrationError>()(
133 "DesktopConnectionCatalogStoreMigrationError",
134 {
135 operation: DesktopConnectionCatalogStoreMigrationOperation,
136 catalogPath: Schema.String,
137 environmentId: Schema.optionalKey(Schema.String),
138 cause: Schema.Defect(),
139 },
140) {
141 override get message(): string {
142 const environment =
143 this.environmentId === undefined ? "" : ` for environment ${this.environmentId}`;
144 return `Legacy desktop saved-environment migration failed during ${this.operation}${environment} into ${this.catalogPath}.`;
145 }
109146}
110147
148const migrationError = (
149 operation: DesktopConnectionCatalogStoreMigrationOperation,
150 catalogPath: string,
151 cause: unknown,
152 environmentId?: string,
153): DesktopConnectionCatalogStoreMigrationError =>
154 new DesktopConnectionCatalogStoreMigrationError({
155 operation,
156 catalogPath,
157 ...(environmentId === undefined ? {} : { environmentId }),
158 cause,
159 });
160
111161export class DesktopConnectionCatalogStore extends Context.Service<
112162 DesktopConnectionCatalogStore,
113 DesktopConnectionCatalogStoreShape
163 {
164 readonly get: Effect.Effect<
165 Option.Option<string>,
166 | DesktopConnectionCatalogStoreReadError
167 | DesktopConnectionCatalogStoreDocumentDecodeError
168 | DesktopConnectionCatalogStoreDecodeError
169 | DesktopConnectionCatalogStoreMigrationError
170 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
171 | ElectronSafeStorage.ElectronSafeStorageDecryptError
172 >;
173 readonly set: (
174 catalog: string,
175 ) => Effect.Effect<
176 boolean,
177 | DesktopConnectionCatalogStoreWriteError
178 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
179 | ElectronSafeStorage.ElectronSafeStorageEncryptError
180 >;
181 readonly clear: Effect.Effect<void>;
182 }
114183>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}
115184
116185function decodeSecretBytes(
186 catalogPath: string,
117187 encoded: string,
118188): Effect.Effect<Uint8Array, DesktopConnectionCatalogStoreDecodeError> {
119189 return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
120 Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })),
190 Effect.mapError(
191 (cause) =>
192 new DesktopConnectionCatalogStoreDecodeError({
193 operation: "decode-encrypted-catalog",
194 resource: "encryptedCatalog",
195 catalogPath,
196 cause,
197 }),
198 ),
121199 );
122200}
123201
......@@ -126,16 +204,34 @@ const readDocument = (
126204 catalogPath: string,
127205): Effect.Effect<
128206 Option.Option<EncryptedConnectionCatalogDocument>,
129 PlatformError.PlatformError | Schema.SchemaError
207 DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError
130208> =>
131209 fileSystem.readFileString(catalogPath).pipe(
132210 Effect.catch((error) =>
133 error.reason._tag === "NotFound" ? Effect.succeed<string | null>(null) : Effect.fail(error),
211 error.reason._tag === "NotFound"
212 ? Effect.succeed<string | null>(null)
213 : Effect.fail(
214 new DesktopConnectionCatalogStoreReadError({
215 operation: "read-catalog",
216 catalogPath,
217 cause: error,
218 }),
219 ),
134220 ),
135221 Effect.flatMap((raw) =>
136222 raw === null
137223 ? Effect.succeed(Option.none<EncryptedConnectionCatalogDocument>())
138 : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(Effect.map(Option.some)),
224 : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(
225 Effect.map(Option.some),
226 Effect.mapError(
227 (cause) =>
228 new DesktopConnectionCatalogStoreDocumentDecodeError({
229 operation: "decode-catalog-document",
230 catalogPath,
231 cause,
232 }),
233 ),
234 ),
139235 ),
140236 );
141237
......@@ -145,14 +241,24 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(
145241 readonly catalogPath: string;
146242 readonly document: EncryptedConnectionCatalogDocument;
147243 readonly suffix: string;
148}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
244}): Effect.fn.Return<void, DesktopConnectionCatalogStoreWriteError> {
149245 const directory = input.path.dirname(input.catalogPath);
150246 const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;
151 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document);
152 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
247 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe(
248 Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)),
249 );
250 yield* input.fileSystem
251 .makeDirectory(directory, { recursive: true })
252 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
153253 yield* Effect.gen(function* () {
154 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
155 yield* input.fileSystem.rename(tempPath, input.catalogPath);
254 yield* input.fileSystem
255 .writeFileString(tempPath, `${encoded}\n`)
256 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));
257 yield* input.fileSystem
258 .rename(tempPath, input.catalogPath)
259 .pipe(
260 Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)),
261 );
156262 }).pipe(
157263 Effect.ensuring(
158264 input.fileSystem.remove(tempPath, { force: true }).pipe(
......@@ -175,10 +281,11 @@ const migrateSavedEnvironmentRecords = Effect.fn(
175281 "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords",
176282)(function* (
177283 records: readonly PersistedSavedEnvironmentRecord[],
178 savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironmentsShape,
284 savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"],
285 catalogPath: string,
179286): Effect.fn.Return<
180287 RuntimeConnectionCatalogDocumentType,
181 DesktopSavedEnvironments.DesktopSavedEnvironmentsGetSecretError
288 DesktopConnectionCatalogStoreMigrationError
182289> {
183290 const targets: Array<RuntimeConnectionCatalogDocumentType["targets"][number]> = [];
184291 const profiles: Array<RuntimeConnectionCatalogDocumentType["profiles"][number]> = [];
......@@ -232,7 +339,13 @@ const migrateSavedEnvironmentRecords = Effect.fn(
232339 wsBaseUrl: record.wsBaseUrl,
233340 }),
234341 );
235 const token = yield* savedEnvironments.getSecret(record.environmentId);
342 const token = yield* savedEnvironments
343 .getSecret(record.environmentId)
344 .pipe(
345 Effect.mapError((cause) =>
346 migrationError("read-legacy-secret", catalogPath, cause, record.environmentId),
347 ),
348 );
236349 if (Option.isSome(token)) {
237350 credentials.push({
238351 connectionId: id,
......@@ -250,79 +363,82 @@ const migrateSavedEnvironmentRecords = Effect.fn(
250363 };
251364});
252365
253export const layer = Layer.effect(
254 DesktopConnectionCatalogStore,
255 Effect.gen(function* () {
256 const environment = yield* DesktopEnvironment.DesktopEnvironment;
257 const fileSystem = yield* FileSystem.FileSystem;
258 const path = yield* Path.Path;
259 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
260 const crypto = yield* Crypto.Crypto;
261 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
262 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
366export const make = Effect.gen(function* () {
367 const environment = yield* DesktopEnvironment.DesktopEnvironment;
368 const fileSystem = yield* FileSystem.FileSystem;
369 const path = yield* Path.Path;
370 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
371 const crypto = yield* Crypto.Crypto;
372 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
373 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
263374
264 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (
265 catalog: string,
266 ) {
267 const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));
268 const suffix = (yield* crypto.randomUUIDv4.pipe(
269 Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })),
270 )).replace(/-/g, "");
271 yield* writeDocument({
272 fileSystem,
273 path,
274 catalogPath,
275 document: { version: 1, encryptedCatalog },
276 suffix,
277 }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })));
375 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (
376 catalog: string,
377 ) {
378 const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));
379 const suffix = (yield* crypto.randomUUIDv4.pipe(
380 Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)),
381 )).replace(/-/g, "");
382 yield* writeDocument({
383 fileSystem,
384 path,
385 catalogPath,
386 document: { version: 1, encryptedCatalog },
387 suffix,
278388 });
389 });
279390
280 const migrateLegacyCatalog = Effect.gen(function* () {
391 const migrateLegacyCatalog = Effect.gen(function* () {
392 if (!(yield* safeStorage.isEncryptionAvailable)) {
393 return Option.none<string>();
394 }
395 const records = yield* savedEnvironments.getRegistry.pipe(
396 Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)),
397 );
398 if (records.length === 0) {
399 return Option.none<string>();
400 }
401 const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath);
402 const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe(
403 Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)),
404 );
405 yield* writeCatalog(encoded).pipe(
406 Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)),
407 );
408 return Option.some(encoded);
409 });
410
411 return DesktopConnectionCatalogStore.of({
412 get: Effect.gen(function* () {
413 const document = yield* readDocument(fileSystem, catalogPath);
414 if (Option.isNone(document)) {
415 return yield* migrateLegacyCatalog;
416 }
281417 if (!(yield* safeStorage.isEncryptionAvailable)) {
282418 return Option.none<string>();
283419 }
284 const records = yield* savedEnvironments.getRegistry;
285 if (records.length === 0) {
286 return Option.none<string>();
420 const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe(
421 Effect.flatMap(safeStorage.decryptString),
422 );
423 return Option.some(decrypted);
424 }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
425 set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
426 if (!(yield* safeStorage.isEncryptionAvailable)) {
427 return false;
287428 }
288 const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments);
289 const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog);
290 yield* writeCatalog(encoded);
291 return Option.some(encoded);
292 }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreMigrationError({ cause })));
293
294 return DesktopConnectionCatalogStore.of({
295 get: Effect.gen(function* () {
296 const document = yield* readDocument(fileSystem, catalogPath).pipe(
297 Effect.mapError((cause) => new DesktopConnectionCatalogStoreReadError({ cause })),
298 );
299 if (Option.isNone(document)) {
300 return yield* migrateLegacyCatalog;
301 }
302 if (!(yield* safeStorage.isEncryptionAvailable)) {
303 return Option.none<string>();
304 }
305 const decrypted = yield* decodeSecretBytes(document.value.encryptedCatalog).pipe(
306 Effect.flatMap(safeStorage.decryptString),
307 );
308 return Option.some(decrypted);
309 }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
310 set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
311 if (!(yield* safeStorage.isEncryptionAvailable)) {
312 return false;
313 }
314 yield* writeCatalog(catalog);
315 return true;
316 }),
317 clear: fileSystem.remove(catalogPath, { force: true }).pipe(
318 Effect.catch((error) =>
319 Effect.logWarning("Could not clear the desktop connection catalog.", {
320 catalogPath,
321 error,
322 }),
323 ),
324 Effect.withSpan("desktop.connectionCatalogStore.clear"),
429 yield* writeCatalog(catalog);
430 return true;
431 }),
432 clear: fileSystem.remove(catalogPath, { force: true }).pipe(
433 Effect.catch((error) =>
434 Effect.logWarning("Could not clear the desktop connection catalog.", {
435 catalogPath,
436 error,
437 }),
325438 ),
326 });
327 }),
328);
439 Effect.withSpan("desktop.connectionCatalogStore.clear"),
440 ),
441 });
442});
443
444export const layer = Layer.effect(DesktopConnectionCatalogStore, make);
apps/desktop/src/settings/DesktopAppSettings.test.ts+39-20
......@@ -8,11 +8,6 @@ import * as Schema from "effect/Schema";
88
99import * as DesktopConfig from "../app/DesktopConfig.ts";
1010import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import {
12 DEFAULT_DESKTOP_SETTINGS,
13 resolveDefaultDesktopSettings,
14 type DesktopSettings as DesktopSettingsValue,
15} from "./DesktopAppSettings.ts";
1611import * as DesktopAppSettings from "./DesktopAppSettings.ts";
1712
1813const DesktopSettingsPatch = Schema.Struct({
......@@ -82,20 +77,23 @@ describe("DesktopSettings", () => {
8277 withSettings(
8378 Effect.gen(function* () {
8479 const settings = yield* DesktopAppSettings.DesktopAppSettings;
85 assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);
86 assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS);
80 assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
81 assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
8782 }),
8883 ),
8984 );
9085
9186 it("defaults packaged nightly builds to the nightly update channel", () => {
92 assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), {
93 serverExposureMode: "local-only",
94 tailscaleServeEnabled: false,
95 tailscaleServePort: 443,
96 updateChannel: "nightly",
97 updateChannelConfiguredByUser: false,
98 } satisfies DesktopSettingsValue);
87 assert.deepEqual(
88 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
89 {
90 serverExposureMode: "local-only",
91 tailscaleServeEnabled: false,
92 tailscaleServePort: 443,
93 updateChannel: "nightly",
94 updateChannelConfiguredByUser: false,
95 } satisfies DesktopAppSettings.DesktopSettings,
96 );
9997 });
10098
10199 it.effect("loads persisted settings and applies semantic updates", () =>
......@@ -116,7 +114,7 @@ describe("DesktopSettings", () => {
116114 tailscaleServePort: 8443,
117115 updateChannel: "latest",
118116 updateChannelConfiguredByUser: true,
119 } satisfies DesktopSettingsValue);
117 } satisfies DesktopAppSettings.DesktopSettings);
120118
121119 const exposure = yield* settings.setServerExposureMode("local-only");
122120 assert.isTrue(exposure.changed);
......@@ -137,6 +135,27 @@ describe("DesktopSettings", () => {
137135 ),
138136 );
139137
138 it.effect("reports the failed desktop settings write operation and path", () =>
139 withSettings(
140 Effect.gen(function* () {
141 const environment = yield* DesktopEnvironment.DesktopEnvironment;
142 const fileSystem = yield* FileSystem.FileSystem;
143 const settings = yield* DesktopAppSettings.DesktopAppSettings;
144 yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true });
145
146 const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip);
147 assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError);
148 assert.equal(error.operation, "replace-settings-file");
149 assert.equal(error.path, environment.desktopSettingsPath);
150 assert.exists(error.cause);
151 assert.equal(
152 error.message,
153 `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`,
154 );
155 }),
156 ),
157 );
158
140159 it.effect("does not persist no-op semantic updates", () =>
141160 withSettings(
142161 Effect.gen(function* () {
......@@ -167,7 +186,7 @@ describe("DesktopSettings", () => {
167186 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
168187 yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json");
169188
170 assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);
189 assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
171190 }),
172191 ),
173192 );
......@@ -195,7 +214,7 @@ describe("DesktopSettings", () => {
195214 tailscaleServePort: 8443,
196215 updateChannel: "latest",
197216 updateChannelConfiguredByUser: false,
198 } satisfies DesktopSettingsValue);
217 } satisfies DesktopAppSettings.DesktopSettings);
199218 }),
200219 ),
201220 );
......@@ -234,7 +253,7 @@ describe("DesktopSettings", () => {
234253 tailscaleServePort: 443,
235254 updateChannel: "nightly",
236255 updateChannelConfiguredByUser: false,
237 } satisfies DesktopSettingsValue);
256 } satisfies DesktopAppSettings.DesktopSettings);
238257 }),
239258 { appVersion: "0.0.17-nightly.20260415.1" },
240259 ),
......@@ -256,7 +275,7 @@ describe("DesktopSettings", () => {
256275 tailscaleServePort: 443,
257276 updateChannel: "latest",
258277 updateChannelConfiguredByUser: true,
259 } satisfies DesktopSettingsValue);
278 } satisfies DesktopAppSettings.DesktopSettings);
260279 }),
261280 { appVersion: "0.0.17-nightly.20260415.1" },
262281 ),
......@@ -277,7 +296,7 @@ describe("DesktopSettings", () => {
277296 tailscaleServePort: 443,
278297 updateChannel: "latest",
279298 updateChannelConfiguredByUser: false,
280 } satisfies DesktopSettingsValue);
299 } satisfies DesktopAppSettings.DesktopSettings);
281300 }),
282301 ),
283302 );
apps/desktop/src/settings/DesktopAppSettings.ts+108-83
......@@ -7,13 +7,11 @@ import {
77import { fromLenientJson } from "@t3tools/shared/schemaJson";
88import * as Context from "effect/Context";
99import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
1110import * as Effect from "effect/Effect";
1211import * as FileSystem from "effect/FileSystem";
1312import * as Layer from "effect/Layer";
1413import * as Option from "effect/Option";
1514import * as Path from "effect/Path";
16import * as PlatformError from "effect/PlatformError";
1715import * as Schema from "effect/Schema";
1816import * as SynchronizedRef from "effect/SynchronizedRef";
1917
......@@ -63,32 +61,50 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet
6361 changed,
6462});
6563
66export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{
67 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
68}> {
69 override get message() {
70 return `Failed to write desktop settings: ${this.cause.message}`;
64const DesktopSettingsWriteOperation = Schema.Literals([
65 "create-temporary-file-name",
66 "encode-document",
67 "create-directory",
68 "write-temporary-file",
69 "replace-settings-file",
70]);
71type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type;
72
73export class DesktopSettingsWriteError extends Schema.TaggedErrorClass<DesktopSettingsWriteError>()(
74 "DesktopSettingsWriteError",
75 {
76 operation: DesktopSettingsWriteOperation,
77 path: Schema.String,
78 cause: Schema.Defect(),
79 },
80) {
81 override get message(): string {
82 return `Desktop settings write failed during ${this.operation} at ${this.path}.`;
7183 }
7284}
7385
74export interface DesktopAppSettingsShape {
75 readonly load: Effect.Effect<DesktopSettings>;
76 readonly get: Effect.Effect<DesktopSettings>;
77 readonly setServerExposureMode: (
78 mode: DesktopServerExposureMode,
79 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
80 readonly setTailscaleServe: (input: {
81 readonly enabled: boolean;
82 readonly port: Option.Option<number>;
83 }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
84 readonly setUpdateChannel: (
85 channel: DesktopUpdateChannel,
86 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
87}
86const writeError = (
87 operation: DesktopSettingsWriteOperation,
88 path: string,
89 cause: unknown,
90): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause });
8891
8992export class DesktopAppSettings extends Context.Service<
9093 DesktopAppSettings,
91 DesktopAppSettingsShape
94 {
95 readonly load: Effect.Effect<DesktopSettings>;
96 readonly get: Effect.Effect<DesktopSettings>;
97 readonly setServerExposureMode: (
98 mode: DesktopServerExposureMode,
99 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
100 readonly setTailscaleServe: (input: {
101 readonly enabled: boolean;
102 readonly port: Option.Option<number>;
103 }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
104 readonly setUpdateChannel: (
105 channel: DesktopUpdateChannel,
106 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
107 }
92108>()("@t3tools/desktop/settings/DesktopAppSettings") {}
93109
94110export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
......@@ -223,77 +239,86 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
223239 readonly settings: DesktopSettings;
224240 readonly defaultSettings: DesktopSettings;
225241 readonly suffix: string;
226}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
242}): Effect.fn.Return<void, DesktopSettingsWriteError> {
227243 const directory = input.path.dirname(input.settingsPath);
228244 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
229245 const encoded = yield* encodeDesktopSettingsJson(
230246 toDesktopSettingsDocument(input.settings, input.defaultSettings),
231 );
232 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
233 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
234 yield* input.fileSystem.rename(tempPath, input.settingsPath);
247 ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)));
248 yield* input.fileSystem
249 .makeDirectory(directory, { recursive: true })
250 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
251 yield* input.fileSystem
252 .writeFileString(tempPath, `${encoded}\n`)
253 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));
254 yield* input.fileSystem
255 .rename(tempPath, input.settingsPath)
256 .pipe(
257 Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)),
258 );
235259});
236260
237export const layer = Layer.effect(
238 DesktopAppSettings,
239 Effect.gen(function* () {
240 const environment = yield* DesktopEnvironment.DesktopEnvironment;
241 const fileSystem = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const crypto = yield* Crypto.Crypto;
244 const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
245
246 const persist = (
247 update: (settings: DesktopSettings) => DesktopSettings,
248 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
249 SynchronizedRef.modifyEffect(settingsRef, (settings) => {
250 const nextSettings = update(settings);
251 if (nextSettings === settings) {
252 return Effect.succeed([settingsChange(settings, false), settings] as const);
253 }
254
255 return crypto.randomUUIDv4.pipe(
256 Effect.map((uuid) => uuid.replace(/-/g, "")),
257 Effect.flatMap((suffix) =>
258 writeSettings({
259 fileSystem,
260 path,
261 settingsPath: environment.desktopSettingsPath,
262 settings: nextSettings,
263 defaultSettings: environment.defaultDesktopSettings,
264 suffix,
265 }),
266 ),
267 Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })),
268 Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
269 );
270 });
261export const make = Effect.gen(function* () {
262 const environment = yield* DesktopEnvironment.DesktopEnvironment;
263 const fileSystem = yield* FileSystem.FileSystem;
264 const path = yield* Path.Path;
265 const crypto = yield* Crypto.Crypto;
266 const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
271267
272 return DesktopAppSettings.of({
273 get: SynchronizedRef.get(settingsRef),
274 load: Effect.gen(function* () {
275 const settings = yield* readSettings(
276 fileSystem,
277 environment.desktopSettingsPath,
278 environment.appVersion,
279 );
280 return yield* SynchronizedRef.setAndGet(settingsRef, settings);
281 }).pipe(Effect.withSpan("desktop.settings.load")),
282 setServerExposureMode: (mode) =>
283 persist((settings) => setServerExposureMode(settings, mode)).pipe(
284 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),
285 ),
286 setTailscaleServe: (input) =>
287 persist((settings) => setTailscaleServe(settings, input)).pipe(
288 Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }),
268 const persist = (
269 update: (settings: DesktopSettings) => DesktopSettings,
270 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
271 SynchronizedRef.modifyEffect(settingsRef, (settings) => {
272 const nextSettings = update(settings);
273 if (nextSettings === settings) {
274 return Effect.succeed([settingsChange(settings, false), settings] as const);
275 }
276
277 return crypto.randomUUIDv4.pipe(
278 Effect.map((uuid) => uuid.replace(/-/g, "")),
279 Effect.mapError((cause) =>
280 writeError("create-temporary-file-name", environment.desktopSettingsPath, cause),
289281 ),
290 setUpdateChannel: (channel) =>
291 persist((settings) => setUpdateChannel(settings, channel)).pipe(
292 Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),
282 Effect.flatMap((suffix) =>
283 writeSettings({
284 fileSystem,
285 path,
286 settingsPath: environment.desktopSettingsPath,
287 settings: nextSettings,
288 defaultSettings: environment.defaultDesktopSettings,
289 suffix,
290 }),
293291 ),
292 Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
293 );
294294 });
295 }),
296);
295
296 return DesktopAppSettings.of({
297 get: SynchronizedRef.get(settingsRef),
298 load: Effect.gen(function* () {
299 const settings = yield* readSettings(
300 fileSystem,
301 environment.desktopSettingsPath,
302 environment.appVersion,
303 );
304 return yield* SynchronizedRef.setAndGet(settingsRef, settings);
305 }).pipe(Effect.withSpan("desktop.settings.load")),
306 setServerExposureMode: (mode) =>
307 persist((settings) => setServerExposureMode(settings, mode)).pipe(
308 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),
309 ),
310 setTailscaleServe: (input) =>
311 persist((settings) => setTailscaleServe(settings, input)).pipe(
312 Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }),
313 ),
314 setUpdateChannel: (channel) =>
315 persist((settings) => setUpdateChannel(settings, channel)).pipe(
316 Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),
317 ),
318 });
319});
320
321export const layer = Layer.effect(DesktopAppSettings, make);
297322
298323export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>
299324 Layer.effect(
apps/desktop/src/settings/DesktopClientSettings.test.ts+21-1
......@@ -34,7 +34,6 @@ const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(Clien
3434const decodeRecordJson = Schema.decodeEffect(
3535 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
3636);
37
3837function makeLayer(baseDir: string) {
3938 const environmentLayer = DesktopEnvironment.layer({
4039 dirname: "/repo/apps/desktop/src",
......@@ -106,6 +105,27 @@ describe("DesktopClientSettings", () => {
106105 ),
107106 );
108107
108 it.effect("reports the failed client settings write operation and path", () =>
109 withClientSettings(
110 Effect.gen(function* () {
111 const environment = yield* DesktopEnvironment.DesktopEnvironment;
112 const fileSystem = yield* FileSystem.FileSystem;
113 const settings = yield* DesktopClientSettings.DesktopClientSettings;
114 yield* fileSystem.makeDirectory(environment.clientSettingsPath, { recursive: true });
115
116 const error = yield* settings.set(clientSettings).pipe(Effect.flip);
117 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError);
118 assert.equal(error.operation, "replace-settings-file");
119 assert.equal(error.path, environment.clientSettingsPath);
120 assert.exists(error.cause);
121 assert.equal(
122 error.message,
123 `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`,
124 );
125 }),
126 ),
127 );
128
109129 it.effect("loads lenient direct client settings documents", () =>
110130 withClientSettings(
111131 Effect.gen(function* () {
apps/desktop/src/settings/DesktopClientSettings.ts+74-46
......@@ -2,13 +2,11 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
22import { fromLenientJson } from "@t3tools/shared/schemaJson";
33import * as Context from "effect/Context";
44import * as Crypto from "effect/Crypto";
5import * as Data from "effect/Data";
65import * as Effect from "effect/Effect";
76import * as FileSystem from "effect/FileSystem";
87import * as Layer from "effect/Layer";
98import * as Option from "effect/Option";
109import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
1210import * as Schema from "effect/Schema";
1311import * as Ref from "effect/Ref";
1412
......@@ -31,24 +29,43 @@ const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Sc
3129 );
3230const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);
3331
34export class DesktopClientSettingsWriteError extends Data.TaggedError(
32const DesktopClientSettingsWriteOperation = Schema.Literals([
33 "create-temporary-file-name",
34 "encode-document",
35 "create-directory",
36 "write-temporary-file",
37 "replace-settings-file",
38]);
39type DesktopClientSettingsWriteOperation = typeof DesktopClientSettingsWriteOperation.Type;
40
41export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<DesktopClientSettingsWriteError>()(
3542 "DesktopClientSettingsWriteError",
36)<{
37 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
38}> {
39 override get message() {
40 return `Failed to write desktop client settings: ${this.cause.message}`;
43 {
44 operation: DesktopClientSettingsWriteOperation,
45 path: Schema.String,
46 cause: Schema.Defect(),
47 },
48) {
49 override get message(): string {
50 return `Desktop client settings write failed during ${this.operation} at ${this.path}.`;
4151 }
4252}
4353
44export interface DesktopClientSettingsShape {
45 readonly get: Effect.Effect<Option.Option<ClientSettings>>;
46 readonly set: (settings: ClientSettings) => Effect.Effect<void, DesktopClientSettingsWriteError>;
47}
54const writeError = (
55 operation: DesktopClientSettingsWriteOperation,
56 path: string,
57 cause: unknown,
58): DesktopClientSettingsWriteError =>
59 new DesktopClientSettingsWriteError({ operation, path, cause });
4860
4961export class DesktopClientSettings extends Context.Service<
5062 DesktopClientSettings,
51 DesktopClientSettingsShape
63 {
64 readonly get: Effect.Effect<Option.Option<ClientSettings>>;
65 readonly set: (
66 settings: ClientSettings,
67 ) => Effect.Effect<void, DesktopClientSettingsWriteError>;
68 }
5269>()("@t3tools/desktop/settings/DesktopClientSettings") {}
5370
5471const readClientSettings = (
......@@ -75,45 +92,56 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
7592 readonly settingsPath: string;
7693 readonly settings: ClientSettings;
7794 readonly suffix: string;
78}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
95}): Effect.fn.Return<void, DesktopClientSettingsWriteError> {
7996 const directory = input.path.dirname(input.settingsPath);
8097 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
81 const encoded = yield* encodeClientSettingsJson(input.settings);
82 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
83 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
84 yield* input.fileSystem.rename(tempPath, input.settingsPath);
98 const encoded = yield* encodeClientSettingsJson(input.settings).pipe(
99 Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)),
100 );
101 yield* input.fileSystem
102 .makeDirectory(directory, { recursive: true })
103 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
104 yield* input.fileSystem
105 .writeFileString(tempPath, `${encoded}\n`)
106 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));
107 yield* input.fileSystem
108 .rename(tempPath, input.settingsPath)
109 .pipe(
110 Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)),
111 );
85112});
86113
87export const layer = Layer.effect(
88 DesktopClientSettings,
89 Effect.gen(function* () {
90 const environment = yield* DesktopEnvironment.DesktopEnvironment;
91 const fileSystem = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const crypto = yield* Crypto.Crypto;
114export const make = Effect.gen(function* () {
115 const environment = yield* DesktopEnvironment.DesktopEnvironment;
116 const fileSystem = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;
118 const crypto = yield* Crypto.Crypto;
94119
95 return DesktopClientSettings.of({
96 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
97 Effect.withSpan("desktop.clientSettings.get"),
98 ),
99 set: (settings) =>
100 crypto.randomUUIDv4.pipe(
101 Effect.map((uuid) => uuid.replace(/-/g, "")),
102 Effect.flatMap((suffix) =>
103 writeClientSettings({
104 fileSystem,
105 path,
106 settingsPath: environment.clientSettingsPath,
107 settings,
108 suffix,
109 }),
110 ),
111 Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })),
112 Effect.withSpan("desktop.clientSettings.set"),
120 return DesktopClientSettings.of({
121 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
122 Effect.withSpan("desktop.clientSettings.get"),
123 ),
124 set: (settings) =>
125 crypto.randomUUIDv4.pipe(
126 Effect.map((uuid) => uuid.replace(/-/g, "")),
127 Effect.mapError((cause) =>
128 writeError("create-temporary-file-name", environment.clientSettingsPath, cause),
113129 ),
114 });
115 }),
116);
130 Effect.flatMap((suffix) =>
131 writeClientSettings({
132 fileSystem,
133 path,
134 settingsPath: environment.clientSettingsPath,
135 settings,
136 suffix,
137 }),
138 ),
139 Effect.withSpan("desktop.clientSettings.set"),
140 ),
141 });
142});
143
144export const layer = Layer.effect(DesktopClientSettings, make);
117145
118146export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>
119147 Layer.effect(
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+138-16
......@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55import * as FileSystem from "effect/FileSystem";
66import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
89import * as Schema from "effect/Schema";
910
1011import * as DesktopConfig from "../app/DesktopConfig.ts";
......@@ -34,10 +35,15 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
3435 version: Schema.Number,
3536 records: Schema.Array(Schema.Unknown),
3637});
38const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(
39 SavedEnvironmentRegistryDocumentProbe,
40);
3741const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
38 Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe),
42 SavedEnvironmentRegistryDocumentProbeJson,
43);
44const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(
45 SavedEnvironmentRegistryDocumentProbeJson,
3946);
40
4147function makeSafeStorageLayer(input: {
4248 readonly available: boolean;
4349 readonly availabilityError?: unknown;
......@@ -80,7 +86,7 @@ function makeSafeStorageLayer(input: {
8086 }
8187 return Effect.succeed(decoded.slice("enc:".length));
8288 },
83 } satisfies ElectronSafeStorage.ElectronSafeStorageShape);
89 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
8490}
8591
8692function makeLayer(
......@@ -91,6 +97,7 @@ function makeLayer(
9197 readonly encryptError?: unknown;
9298 readonly decryptError?: unknown;
9399 },
100 fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,
94101) {
95102 const environmentLayer = DesktopEnvironment.layer({
96103 dirname: "/repo/apps/desktop/src",
......@@ -108,18 +115,20 @@ function makeLayer(
108115 ),
109116 );
110117
111 return DesktopSavedEnvironments.layer.pipe(
112 Layer.provideMerge(environmentLayer),
113 Layer.provideMerge(
114 makeSafeStorageLayer({
115 available: options?.availableSecretStorage ?? true,
116 availabilityError: options?.availabilityError,
117 encryptError: options?.encryptError,
118 decryptError: options?.decryptError,
119 }),
120 ),
121 Layer.provideMerge(NodeServices.layer),
118 const safeStorageLayer = makeSafeStorageLayer({
119 available: options?.availableSecretStorage ?? true,
120 availabilityError: options?.availabilityError,
121 encryptError: options?.encryptError,
122 decryptError: options?.decryptError,
123 });
124 const dependencies = Layer.mergeAll(
125 environmentLayer,
126 safeStorageLayer,
127 NodeServices.layer,
128 fileSystemLayer,
122129 );
130
131 return DesktopSavedEnvironments.layer.pipe(Layer.provideMerge(dependencies));
123132}
124133
125134const withSavedEnvironments = <A, E, R>(
......@@ -215,6 +224,37 @@ describe("DesktopSavedEnvironments", () => {
215224 ),
216225 );
217226
227 it.effect("reports invalid saved secret encoding without exposing the secret", () =>
228 withSavedEnvironments(
229 Effect.gen(function* () {
230 const environment = yield* DesktopEnvironment.DesktopEnvironment;
231 const fileSystem = yield* FileSystem.FileSystem;
232 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
233 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
234 const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
235 version: 1,
236 records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }],
237 });
238 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
239
240 const error = yield* savedEnvironments
241 .getSecret(savedRegistryRecord.environmentId)
242 .pipe(Effect.flip);
243 assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError);
244 assert.equal(error.operation, "decode-secret");
245 assert.equal(error.environmentId, savedRegistryRecord.environmentId);
246 assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath);
247 assert.equal(error.field, "encryptedBearerToken");
248 assert.exists(error.cause);
249 assert.equal(
250 error.message,
251 `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`,
252 );
253 assert.notInclude(error.message, "%%%");
254 }),
255 ),
256 );
257
218258 it.effect("returns false when writing secrets while encryption is unavailable", () =>
219259 withSavedEnvironments(
220260 Effect.gen(function* () {
......@@ -321,16 +361,98 @@ describe("DesktopSavedEnvironments", () => {
321361 const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip);
322362 assert.instanceOf(
323363 registryError,
324 DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError,
364 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
325365 );
366 assert.equal(registryError.operation, "decode-registry");
367 assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath);
368 assert.exists(registryError.cause);
326369 const secretError = yield* savedEnvironments
327370 .getSecret(savedRegistryRecord.environmentId)
328371 .pipe(Effect.flip);
329 assert.instanceOf(secretError, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError);
372 assert.instanceOf(
373 secretError,
374 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
375 );
376 const mutationError = yield* savedEnvironments
377 .setRegistry([savedRegistryRecord])
378 .pipe(Effect.flip);
379 assert.instanceOf(
380 mutationError,
381 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
382 );
330383 }),
331384 ),
332385 );
333386
387 it.effect("reports saved environment filesystem reads separately from document decoding", () =>
388 Effect.gen(function* () {
389 const baseFileSystem = yield* FileSystem.FileSystem;
390 const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
391 prefix: "t3-desktop-saved-environments-test-",
392 });
393 const registryPath = `${baseDir}/userdata/saved-environments.json`;
394 const permissionError = PlatformError.systemError({
395 _tag: "PermissionDenied",
396 module: "FileSystem",
397 method: "readFileString",
398 pathOrDescriptor: registryPath,
399 });
400 const fileSystemLayer = Layer.succeed(
401 FileSystem.FileSystem,
402 FileSystem.makeNoop({
403 readFileString: () => Effect.fail(permissionError),
404 }),
405 );
406 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe(
407 Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)),
408 );
409
410 const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip);
411 assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError);
412 assert.equal(error.operation, "read-registry");
413 assert.equal(error.registryPath, registryPath);
414 assert.strictEqual(error.cause, permissionError);
415 assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`);
416 assert.notEqual(error.message, permissionError.message);
417 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
418 );
419
420 it.effect("reports the failed saved environment write operation and path", () =>
421 Effect.gen(function* () {
422 const baseFileSystem = yield* FileSystem.FileSystem;
423 const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
424 prefix: "t3-desktop-saved-environments-test-",
425 });
426 const permissionError = PlatformError.systemError({
427 _tag: "PermissionDenied",
428 module: "FileSystem",
429 method: "makeDirectory",
430 pathOrDescriptor: `${baseDir}/userdata`,
431 });
432 const fileSystemLayer = Layer.succeed(
433 FileSystem.FileSystem,
434 FileSystem.makeNoop({
435 readFileString: baseFileSystem.readFileString,
436 makeDirectory: () => Effect.fail(permissionError),
437 }),
438 );
439 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe(
440 Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)),
441 );
442
443 const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip);
444 assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError);
445 assert.equal(error.operation, "create-directory");
446 assert.equal(error.path, `${baseDir}/userdata`);
447 assert.strictEqual(error.cause, permissionError);
448 assert.equal(
449 error.message,
450 `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`,
451 );
452 assert.notEqual(error.message, permissionError.message);
453 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
454 );
455
334456 it.effect("returns false when writing a secret without metadata", () =>
335457 withSavedEnvironments(
336458 Effect.gen(function* () {
apps/desktop/src/settings/DesktopSavedEnvironments.ts+260-176
......@@ -2,14 +2,12 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co
22import { fromLenientJson } from "@t3tools/shared/schemaJson";
33import * as Context from "effect/Context";
44import * as Crypto from "effect/Crypto";
5import * as Data from "effect/Data";
65import * as Effect from "effect/Effect";
76import * as Encoding from "effect/Encoding";
87import * as FileSystem from "effect/FileSystem";
98import * as Layer from "effect/Layer";
109import * as Option from "effect/Option";
1110import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
1311import * as Schema from "effect/Schema";
1412import * as Ref from "effect/Ref";
1513
......@@ -72,73 +70,123 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(
7270 SavedEnvironmentRegistryDocumentJson,
7371);
7472
75export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError(
73const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([
74 "create-temporary-file-name",
75 "encode-registry",
76 "create-directory",
77 "write-temporary-file",
78 "replace-registry-file",
79]);
80type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type;
81
82export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsWriteError>()(
7683 "DesktopSavedEnvironmentsWriteError",
77)<{
78 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
79}> {
80 override get message() {
81 return `Failed to write desktop saved environments: ${this.cause.message}`;
84 {
85 operation: DesktopSavedEnvironmentsWriteOperation,
86 path: Schema.String,
87 cause: Schema.Defect(),
88 },
89) {
90 override get message(): string {
91 return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`;
8292 }
8393}
8494
85export class DesktopSavedEnvironmentsReadError extends Data.TaggedError(
95const writeError = (
96 operation: DesktopSavedEnvironmentsWriteOperation,
97 path: string,
98 cause: unknown,
99): DesktopSavedEnvironmentsWriteError =>
100 new DesktopSavedEnvironmentsWriteError({
101 operation,
102 path,
103 cause,
104 });
105
106export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsReadError>()(
86107 "DesktopSavedEnvironmentsReadError",
87)<{
88 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
89}> {
90 override get message() {
91 return `Failed to read desktop saved environments: ${this.cause.message}`;
108 {
109 operation: Schema.Literal("read-registry"),
110 registryPath: Schema.String,
111 cause: Schema.Defect(),
112 },
113) {
114 override get message(): string {
115 return `Failed to read desktop saved environments at ${this.registryPath}.`;
92116 }
93117}
94118
95export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError(
119export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentsDocumentDecodeError>()(
120 "DesktopSavedEnvironmentsDocumentDecodeError",
121 {
122 operation: Schema.Literal("decode-registry"),
123 registryPath: Schema.String,
124 cause: Schema.Defect(),
125 },
126) {
127 override get message(): string {
128 return `Failed to decode desktop saved environments at ${this.registryPath}.`;
129 }
130}
131
132export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedErrorClass<DesktopSavedEnvironmentSecretDecodeError>()(
96133 "DesktopSavedEnvironmentSecretDecodeError",
97)<{
98 readonly cause: Encoding.EncodingError;
99}> {
100 override get message() {
101 return "Failed to decode desktop saved environment secret.";
134 {
135 operation: Schema.Literal("decode-secret"),
136 environmentId: Schema.String,
137 registryPath: Schema.String,
138 field: Schema.Literal("encryptedBearerToken"),
139 cause: Schema.Defect(),
140 },
141) {
142 override get message(): string {
143 return `Failed to decode ${this.field} for environment ${this.environmentId} at ${this.registryPath}.`;
102144 }
103145}
104146
105export type DesktopSavedEnvironmentsGetSecretError =
147export type DesktopSavedEnvironmentsReadRegistryError =
106148 | DesktopSavedEnvironmentsReadError
149 | DesktopSavedEnvironmentsDocumentDecodeError;
150
151export type DesktopSavedEnvironmentsMutationError =
152 | DesktopSavedEnvironmentsReadRegistryError
153 | DesktopSavedEnvironmentsWriteError;
154
155export type DesktopSavedEnvironmentsGetSecretError =
156 | DesktopSavedEnvironmentsReadRegistryError
107157 | DesktopSavedEnvironmentSecretDecodeError
108158 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
109159 | ElectronSafeStorage.ElectronSafeStorageDecryptError;
110160
111161export type DesktopSavedEnvironmentsSetSecretError =
112 | DesktopSavedEnvironmentsWriteError
162 | DesktopSavedEnvironmentsMutationError
113163 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
114164 | ElectronSafeStorage.ElectronSafeStorageEncryptError;
115165
116export interface DesktopSavedEnvironmentsShape {
117 readonly getRegistry: Effect.Effect<
118 readonly PersistedSavedEnvironmentRecord[],
119 DesktopSavedEnvironmentsReadError
120 >;
121 readonly setRegistry: (
122 records: readonly PersistedSavedEnvironmentRecord[],
123 ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
124 readonly removeEnvironment: (
125 environmentId: string,
126 ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
127 readonly getSecret: (
128 environmentId: string,
129 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
130 readonly setSecret: (input: {
131 readonly environmentId: string;
132 readonly secret: string;
133 }) => Effect.Effect<boolean, DesktopSavedEnvironmentsSetSecretError>;
134 readonly removeSecret: (
135 environmentId: string,
136 ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
137}
138
139166export class DesktopSavedEnvironments extends Context.Service<
140167 DesktopSavedEnvironments,
141 DesktopSavedEnvironmentsShape
168 {
169 readonly getRegistry: Effect.Effect<
170 readonly PersistedSavedEnvironmentRecord[],
171 DesktopSavedEnvironmentsReadRegistryError
172 >;
173 readonly setRegistry: (
174 records: readonly PersistedSavedEnvironmentRecord[],
175 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
176 readonly removeEnvironment: (
177 environmentId: string,
178 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
179 readonly getSecret: (
180 environmentId: string,
181 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
182 readonly setSecret: (input: {
183 readonly environmentId: string;
184 readonly secret: string;
185 }) => Effect.Effect<boolean, DesktopSavedEnvironmentsSetSecretError>;
186 readonly removeSecret: (
187 environmentId: string,
188 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
189 }
142190>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
143191
144192function toPersistedSavedEnvironmentRecord(
......@@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument(
193241function readRegistryDocument(
194242 fileSystem: FileSystem.FileSystem,
195243 registryPath: string,
196): Effect.Effect<
197 SavedEnvironmentRegistryDocument,
198 PlatformError.PlatformError | Schema.SchemaError
199> {
244): Effect.Effect<SavedEnvironmentRegistryDocument, DesktopSavedEnvironmentsReadRegistryError> {
200245 return fileSystem.readFileString(registryPath).pipe(
201246 Effect.catch((error) =>
202 error.reason._tag === "NotFound" ? Effect.succeed<string | null>(null) : Effect.fail(error),
247 error.reason._tag === "NotFound"
248 ? Effect.succeed<string | null>(null)
249 : Effect.fail(
250 new DesktopSavedEnvironmentsReadError({
251 operation: "read-registry",
252 registryPath,
253 cause: error,
254 }),
255 ),
203256 ),
204257 Effect.flatMap((raw) =>
205258 raw === null
206259 ? Effect.succeed({ version: 1, records: [] })
207260 : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(
208261 Effect.map(normalizeSavedEnvironmentRegistryDocument),
262 Effect.mapError(
263 (cause) =>
264 new DesktopSavedEnvironmentsDocumentDecodeError({
265 operation: "decode-registry",
266 registryPath,
267 cause,
268 }),
269 ),
209270 ),
210271 ),
211272 );
......@@ -218,13 +279,23 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry
218279 readonly registryPath: string;
219280 readonly document: SavedEnvironmentRegistryDocument;
220281 readonly suffix: string;
221 }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
282 }): Effect.fn.Return<void, DesktopSavedEnvironmentsWriteError> {
222283 const directory = input.path.dirname(input.registryPath);
223284 const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;
224 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document);
225 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
226 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
227 yield* input.fileSystem.rename(tempPath, input.registryPath);
285 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe(
286 Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)),
287 );
288 yield* input.fileSystem
289 .makeDirectory(directory, { recursive: true })
290 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
291 yield* input.fileSystem
292 .writeFileString(tempPath, `${encoded}\n`)
293 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));
294 yield* input.fileSystem
295 .rename(tempPath, input.registryPath)
296 .pipe(
297 Effect.mapError((cause) => writeError("replace-registry-file", input.registryPath, cause)),
298 );
228299 },
229300);
230301
......@@ -250,147 +321,160 @@ function preserveExistingSecrets(
250321}
251322
252323function decodeSecretBytes(
324 environmentId: string,
325 registryPath: string,
253326 encoded: string,
254327): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {
255328 return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
256 Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })),
329 Effect.mapError(
330 (cause) =>
331 new DesktopSavedEnvironmentSecretDecodeError({
332 operation: "decode-secret",
333 environmentId,
334 registryPath,
335 field: "encryptedBearerToken",
336 cause,
337 }),
338 ),
257339 );
258340}
259341
260export const layer = Layer.effect(
261 DesktopSavedEnvironments,
262 Effect.gen(function* () {
263 const environment = yield* DesktopEnvironment.DesktopEnvironment;
264 const fileSystem = yield* FileSystem.FileSystem;
265 const path = yield* Path.Path;
266 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
267 const crypto = yield* Crypto.Crypto;
268
269 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
270 crypto.randomUUIDv4.pipe(
271 Effect.map((uuid) => uuid.replace(/-/g, "")),
272 Effect.flatMap((suffix) =>
273 writeRegistryDocument({
274 fileSystem,
275 path,
276 registryPath: environment.savedEnvironmentRegistryPath,
277 document,
278 suffix,
279 }),
280 ),
281 Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })),
282 );
283
284 return DesktopSavedEnvironments.of({
285 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
286 Effect.map((document) =>
287 document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
288 ),
289 Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause })),
290 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
342export const make = Effect.gen(function* () {
343 const environment = yield* DesktopEnvironment.DesktopEnvironment;
344 const fileSystem = yield* FileSystem.FileSystem;
345 const path = yield* Path.Path;
346 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
347 const crypto = yield* Crypto.Crypto;
348
349 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
350 crypto.randomUUIDv4.pipe(
351 Effect.map((uuid) => uuid.replace(/-/g, "")),
352 Effect.mapError((cause) =>
353 writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause),
291354 ),
292 setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {
293 const currentDocument = yield* readRegistryDocument(
355 Effect.flatMap((suffix) =>
356 writeRegistryDocument({
294357 fileSystem,
295 environment.savedEnvironmentRegistryPath,
296 ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
297 yield* writeDocument(preserveExistingSecrets(currentDocument, records));
298 }),
299 removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")(
300 function* (environmentId) {
301 yield* Effect.annotateCurrentSpan({ environmentId });
302 const document = yield* readRegistryDocument(
303 fileSystem,
304 environment.savedEnvironmentRegistryPath,
305 ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
306 if (!document.records.some((record) => record.environmentId === environmentId)) {
307 return;
308 }
309
310 yield* writeDocument({
311 version: document.version,
312 records: document.records.filter((record) => record.environmentId !== environmentId),
313 });
314 },
358 path,
359 registryPath: environment.savedEnvironmentRegistryPath,
360 document,
361 suffix,
362 }),
315363 ),
316 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
317 yield* Effect.annotateCurrentSpan({ environmentId });
318 const document = yield* readRegistryDocument(
319 fileSystem,
320 environment.savedEnvironmentRegistryPath,
321 ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause })));
322 const encoded = Option.fromNullishOr(
323 document.records.find((record) => record.environmentId === environmentId)
324 ?.encryptedBearerToken,
325 );
326 if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) {
327 return Option.none<string>();
328 }
364 );
329365
330 const secretBytes = yield* decodeSecretBytes(encoded.value);
331 return Option.some(yield* safeStorage.decryptString(secretBytes));
332 }),
333 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
334 const { environmentId, secret } = input;
366 return DesktopSavedEnvironments.of({
367 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
368 Effect.map((document) =>
369 document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
370 ),
371 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
372 ),
373 setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {
374 const currentDocument = yield* readRegistryDocument(
375 fileSystem,
376 environment.savedEnvironmentRegistryPath,
377 );
378 yield* writeDocument(preserveExistingSecrets(currentDocument, records));
379 }),
380 removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")(
381 function* (environmentId) {
335382 yield* Effect.annotateCurrentSpan({ environmentId });
336383 const document = yield* readRegistryDocument(
337384 fileSystem,
338385 environment.savedEnvironmentRegistryPath,
339 ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
340
341 if (!(yield* safeStorage.isEncryptionAvailable)) {
342 return false;
343 }
344
345 const encryptedBearerToken = Encoding.encodeBase64(
346 yield* safeStorage.encryptString(secret),
347386 );
348 let found = false;
349 const nextDocument: SavedEnvironmentRegistryDocument = {
350 version: document.version,
351 records: document.records.map((record) => {
352 if (record.environmentId !== environmentId) {
353 return record;
354 }
355
356 found = true;
357 return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken));
358 }),
359 };
360
361 if (found) {
362 yield* writeDocument(nextDocument);
363 }
364 return found;
365 }),
366 removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) {
367 yield* Effect.annotateCurrentSpan({ environmentId });
368 const document = yield* readRegistryDocument(
369 fileSystem,
370 environment.savedEnvironmentRegistryPath,
371 ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
372 if (
373 !document.records.some(
374 (record) =>
375 record.environmentId === environmentId && record.encryptedBearerToken !== undefined,
376 )
377 ) {
387 if (!document.records.some((record) => record.environmentId === environmentId)) {
378388 return;
379389 }
380390
381391 yield* writeDocument({
382392 version: document.version,
383 records: document.records.map((record) => {
384 if (record.environmentId !== environmentId) {
385 return record;
386 }
387 return toPersistedSavedEnvironmentRecord(record);
388 }),
393 records: document.records.filter((record) => record.environmentId !== environmentId),
389394 });
390 }),
391 });
392 }),
393);
395 },
396 ),
397 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
398 yield* Effect.annotateCurrentSpan({ environmentId });
399 const document = yield* readRegistryDocument(
400 fileSystem,
401 environment.savedEnvironmentRegistryPath,
402 );
403 const encoded = Option.fromNullishOr(
404 document.records.find((record) => record.environmentId === environmentId)
405 ?.encryptedBearerToken,
406 );
407 if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) {
408 return Option.none<string>();
409 }
410
411 const secretBytes = yield* decodeSecretBytes(
412 environmentId,
413 environment.savedEnvironmentRegistryPath,
414 encoded.value,
415 );
416 return Option.some(yield* safeStorage.decryptString(secretBytes));
417 }),
418 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
419 const { environmentId, secret } = input;
420 yield* Effect.annotateCurrentSpan({ environmentId });
421 const document = yield* readRegistryDocument(
422 fileSystem,
423 environment.savedEnvironmentRegistryPath,
424 );
425
426 if (!(yield* safeStorage.isEncryptionAvailable)) {
427 return false;
428 }
429
430 const encryptedBearerToken = Encoding.encodeBase64(yield* safeStorage.encryptString(secret));
431 let found = false;
432 const nextDocument: SavedEnvironmentRegistryDocument = {
433 version: document.version,
434 records: document.records.map((record) => {
435 if (record.environmentId !== environmentId) {
436 return record;
437 }
438
439 found = true;
440 return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken));
441 }),
442 };
443
444 if (found) {
445 yield* writeDocument(nextDocument);
446 }
447 return found;
448 }),
449 removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) {
450 yield* Effect.annotateCurrentSpan({ environmentId });
451 const document = yield* readRegistryDocument(
452 fileSystem,
453 environment.savedEnvironmentRegistryPath,
454 );
455 if (
456 !document.records.some(
457 (record) =>
458 record.environmentId === environmentId && record.encryptedBearerToken !== undefined,
459 )
460 ) {
461 return;
462 }
463
464 yield* writeDocument({
465 version: document.version,
466 records: document.records.map((record) => {
467 if (record.environmentId !== environmentId) {
468 return record;
469 }
470 return toPersistedSavedEnvironmentRecord(record);
471 }),
472 });
473 }),
474 });
475});
476
477export const layer = Layer.effect(DesktopSavedEnvironments, make);
394478
395479export const layerTest = (input?: {
396480 readonly records?: readonly PersistedSavedEnvironmentRecord[];