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();...@@ -21,7 +21,6 @@ const textEncoder = new TextEncoder();
21const decodeConnectionCatalog = Schema.decodeEffect(21const decodeConnectionCatalog = Schema.decodeEffect(
22 Schema.fromJsonString(ConnectionCatalogDocument),22 Schema.fromJsonString(ConnectionCatalogDocument),
23);23);
24
25function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {24function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {
26 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {25 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
27 isEncryptionAvailable: Effect.succeed(available),26 isEncryptionAvailable: Effect.succeed(available),
...@@ -40,7 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean>...@@ -40,7 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean>
40 return decoded.slice("encrypted:".length);39 return decoded.slice("encrypted:".length);
41 });40 });
42 },41 },
43 } satisfies ElectronSafeStorage.ElectronSafeStorageShape);42 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
44}43}
4544
46function makeLayer(45function makeLayer(
...@@ -236,8 +235,11 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -236,8 +235,11 @@ describe("DesktopConnectionCatalogStore", () => {
236 const error = yield* store.get.pipe(Effect.flip);235 const error = yield* store.get.pipe(Effect.flip);
237 assert.instanceOf(236 assert.instanceOf(
238 error,237 error,
239 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError,238 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError,
240 );239 );
240 assert.equal(error.operation, "decode-catalog-document");
241 assert.equal(error.catalogPath, catalogPath);
242 assert.exists(error.cause);
241 assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json");243 assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json");
242 }),244 }),
243 ),245 ),
...@@ -253,7 +255,7 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -253,7 +255,7 @@ describe("DesktopConnectionCatalogStore", () => {
253 _tag: "PermissionDenied",255 _tag: "PermissionDenied",
254 module: "FileSystem",256 module: "FileSystem",
255 method: "readFileString",257 method: "readFileString",
256 pathOrDescriptor: `${baseDir}/connection-catalog.json`,258 pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`,
257 });259 });
258 const fileSystemLayer = Layer.succeed(260 const fileSystemLayer = Layer.succeed(
259 FileSystem.FileSystem,261 FileSystem.FileSystem,
...@@ -270,10 +272,115 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -270,10 +272,115 @@ describe("DesktopConnectionCatalogStore", () => {
270 error,272 error,
271 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError,273 DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError,
272 );274 );
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);
274 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),283 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
275 );284 );
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
277 it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () =>384 it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () =>
278 Effect.gen(function* () {385 Effect.gen(function* () {
279 const fileSystem = yield* FileSystem.FileSystem;386 const fileSystem = yield* FileSystem.FileSystem;
apps/desktop/src/app/DesktopConnectionCatalogStore.ts+242-126
...@@ -14,14 +14,12 @@ import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts";...@@ -14,14 +14,12 @@ import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
14import { fromLenientJson } from "@t3tools/shared/schemaJson";14import { fromLenientJson } from "@t3tools/shared/schemaJson";
15import * as Context from "effect/Context";15import * as Context from "effect/Context";
16import * as Crypto from "effect/Crypto";16import * as Crypto from "effect/Crypto";
17import * as Data from "effect/Data";
18import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
19import * as Encoding from "effect/Encoding";18import * as Encoding from "effect/Encoding";
20import * as FileSystem from "effect/FileSystem";19import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";20import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";21import * as Option from "effect/Option";
23import * as Path from "effect/Path";22import * as Path from "effect/Path";
24import * as PlatformError from "effect/PlatformError";
25import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
2624
27import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";25import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
...@@ -48,76 +46,156 @@ const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect(...@@ -48,76 +46,156 @@ const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect(
48 RuntimeConnectionCatalogDocumentJson,46 RuntimeConnectionCatalogDocumentJson,
49);47);
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>()(
52 "DesktopConnectionCatalogStoreWriteError",69 "DesktopConnectionCatalogStoreWriteError",
53)<{70 {
54 readonly cause: PlatformError.PlatformError | Schema.SchemaError;71 operation: DesktopConnectionCatalogStoreWriteOperation,
55}> {72 path: Schema.String,
56 override get message() {73 cause: Schema.Defect(),
57 return `Failed to write desktop connection catalog: ${this.cause.message}`;74 },
75) {
76 override get message(): string {
77 return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`;
58 }78 }
59}79}
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>()(
62 "DesktopConnectionCatalogStoreDecodeError",93 "DesktopConnectionCatalogStoreDecodeError",
63)<{94 {
64 readonly cause: Encoding.EncodingError;95 operation: Schema.Literal("decode-encrypted-catalog"),
65}> {96 resource: Schema.Literal("encryptedCatalog"),
66 override get message() {97 catalogPath: Schema.String,
67 return "Failed to decode the desktop connection catalog.";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}.`;
68 }103 }
69}104}
70105
71export class DesktopConnectionCatalogStoreReadError extends Data.TaggedError(106export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreReadError>()(
72 "DesktopConnectionCatalogStoreReadError",107 "DesktopConnectionCatalogStoreReadError",
73)<{108 {
74 readonly cause: PlatformError.PlatformError | Schema.SchemaError;109 operation: Schema.Literal("read-catalog"),
75}> {110 catalogPath: Schema.String,
76 override get message() {111 cause: Schema.Defect(),
77 return `Failed to read desktop connection catalog: ${this.cause.message}`;112 },
113) {
114 override get message(): string {
115 return `Failed to read the desktop connection catalog at ${this.catalogPath}.`;
78 }116 }
79}117}
80118
81export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError(119export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreDocumentDecodeError>()(
82 "DesktopConnectionCatalogStoreMigrationError",120 "DesktopConnectionCatalogStoreDocumentDecodeError",
83)<{121 {
84 readonly cause: unknown;122 operation: Schema.Literal("decode-catalog-document"),
85}> {123 catalogPath: Schema.String,
86 override get message() {124 cause: Schema.Defect(),
87 return "Failed to migrate legacy desktop saved environments.";125 },
126) {
127 override get message(): string {
128 return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`;
88 }129 }
89}130}
90131
91export interface DesktopConnectionCatalogStoreShape {132export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreMigrationError>()(
92 readonly get: Effect.Effect<133 "DesktopConnectionCatalogStoreMigrationError",
93 Option.Option<string>,134 {
94 | DesktopConnectionCatalogStoreReadError135 operation: DesktopConnectionCatalogStoreMigrationOperation,
95 | DesktopConnectionCatalogStoreDecodeError136 catalogPath: Schema.String,
96 | DesktopConnectionCatalogStoreMigrationError137 environmentId: Schema.optionalKey(Schema.String),
97 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError138 cause: Schema.Defect(),
98 | ElectronSafeStorage.ElectronSafeStorageDecryptError139 },
99 >;140) {
100 readonly set: (141 override get message(): string {
101 catalog: string,142 const environment =
102 ) => Effect.Effect<143 this.environmentId === undefined ? "" : ` for environment ${this.environmentId}`;
103 boolean,144 return `Legacy desktop saved-environment migration failed during ${this.operation}${environment} into ${this.catalogPath}.`;
104 | DesktopConnectionCatalogStoreWriteError145 }
105 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
106 | ElectronSafeStorage.ElectronSafeStorageEncryptError
107 >;
108 readonly clear: Effect.Effect<void>;
109}146}
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
111export class DesktopConnectionCatalogStore extends Context.Service<161export class DesktopConnectionCatalogStore extends Context.Service<
112 DesktopConnectionCatalogStore,162 DesktopConnectionCatalogStore,
113 DesktopConnectionCatalogStoreShape163 {
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 }
114>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}183>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}
115184
116function decodeSecretBytes(185function decodeSecretBytes(
186 catalogPath: string,
117 encoded: string,187 encoded: string,
118): Effect.Effect<Uint8Array, DesktopConnectionCatalogStoreDecodeError> {188): Effect.Effect<Uint8Array, DesktopConnectionCatalogStoreDecodeError> {
119 return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(189 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 ),
121 );199 );
122}200}
123201
...@@ -126,16 +204,34 @@ const readDocument = (...@@ -126,16 +204,34 @@ const readDocument = (
126 catalogPath: string,204 catalogPath: string,
127): Effect.Effect<205): Effect.Effect<
128 Option.Option<EncryptedConnectionCatalogDocument>,206 Option.Option<EncryptedConnectionCatalogDocument>,
129 PlatformError.PlatformError | Schema.SchemaError207 DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError
130> =>208> =>
131 fileSystem.readFileString(catalogPath).pipe(209 fileSystem.readFileString(catalogPath).pipe(
132 Effect.catch((error) =>210 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 ),
134 ),220 ),
135 Effect.flatMap((raw) =>221 Effect.flatMap((raw) =>
136 raw === null222 raw === null
137 ? Effect.succeed(Option.none<EncryptedConnectionCatalogDocument>())223 ? 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 ),
139 ),235 ),
140 );236 );
141237
...@@ -145,14 +241,24 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(...@@ -145,14 +241,24 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(
145 readonly catalogPath: string;241 readonly catalogPath: string;
146 readonly document: EncryptedConnectionCatalogDocument;242 readonly document: EncryptedConnectionCatalogDocument;
147 readonly suffix: string;243 readonly suffix: string;
148}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {244}): Effect.fn.Return<void, DesktopConnectionCatalogStoreWriteError> {
149 const directory = input.path.dirname(input.catalogPath);245 const directory = input.path.dirname(input.catalogPath);
150 const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;246 const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;
151 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document);247 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe(
152 yield* input.fileSystem.makeDirectory(directory, { recursive: true });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)));
153 yield* Effect.gen(function* () {253 yield* Effect.gen(function* () {
154 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);254 yield* input.fileSystem
155 yield* input.fileSystem.rename(tempPath, input.catalogPath);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 );
156 }).pipe(262 }).pipe(
157 Effect.ensuring(263 Effect.ensuring(
158 input.fileSystem.remove(tempPath, { force: true }).pipe(264 input.fileSystem.remove(tempPath, { force: true }).pipe(
...@@ -175,10 +281,11 @@ const migrateSavedEnvironmentRecords = Effect.fn(...@@ -175,10 +281,11 @@ const migrateSavedEnvironmentRecords = Effect.fn(
175 "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords",281 "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords",
176)(function* (282)(function* (
177 records: readonly PersistedSavedEnvironmentRecord[],283 records: readonly PersistedSavedEnvironmentRecord[],
178 savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironmentsShape,284 savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"],
285 catalogPath: string,
179): Effect.fn.Return<286): Effect.fn.Return<
180 RuntimeConnectionCatalogDocumentType,287 RuntimeConnectionCatalogDocumentType,
181 DesktopSavedEnvironments.DesktopSavedEnvironmentsGetSecretError288 DesktopConnectionCatalogStoreMigrationError
182> {289> {
183 const targets: Array<RuntimeConnectionCatalogDocumentType["targets"][number]> = [];290 const targets: Array<RuntimeConnectionCatalogDocumentType["targets"][number]> = [];
184 const profiles: Array<RuntimeConnectionCatalogDocumentType["profiles"][number]> = [];291 const profiles: Array<RuntimeConnectionCatalogDocumentType["profiles"][number]> = [];
...@@ -232,7 +339,13 @@ const migrateSavedEnvironmentRecords = Effect.fn(...@@ -232,7 +339,13 @@ const migrateSavedEnvironmentRecords = Effect.fn(
232 wsBaseUrl: record.wsBaseUrl,339 wsBaseUrl: record.wsBaseUrl,
233 }),340 }),
234 );341 );
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 );
236 if (Option.isSome(token)) {349 if (Option.isSome(token)) {
237 credentials.push({350 credentials.push({
238 connectionId: id,351 connectionId: id,
...@@ -250,79 +363,82 @@ const migrateSavedEnvironmentRecords = Effect.fn(...@@ -250,79 +363,82 @@ const migrateSavedEnvironmentRecords = Effect.fn(
250 };363 };
251});364});
252365
253export const layer = Layer.effect(366export const make = Effect.gen(function* () {
254 DesktopConnectionCatalogStore,367 const environment = yield* DesktopEnvironment.DesktopEnvironment;
255 Effect.gen(function* () {368 const fileSystem = yield* FileSystem.FileSystem;
256 const environment = yield* DesktopEnvironment.DesktopEnvironment;369 const path = yield* Path.Path;
257 const fileSystem = yield* FileSystem.FileSystem;370 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
258 const path = yield* Path.Path;371 const crypto = yield* Crypto.Crypto;
259 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;372 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
260 const crypto = yield* Crypto.Crypto;373 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
261 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
262 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
263374
264 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (375 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (
265 catalog: string,376 catalog: string,
266 ) {377 ) {
267 const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));378 const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog));
268 const suffix = (yield* crypto.randomUUIDv4.pipe(379 const suffix = (yield* crypto.randomUUIDv4.pipe(
269 Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })),380 Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)),
270 )).replace(/-/g, "");381 )).replace(/-/g, "");
271 yield* writeDocument({382 yield* writeDocument({
272 fileSystem,383 fileSystem,
273 path,384 path,
274 catalogPath,385 catalogPath,
275 document: { version: 1, encryptedCatalog },386 document: { version: 1, encryptedCatalog },
276 suffix,387 suffix,
277 }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })));
278 });388 });
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 }
281 if (!(yield* safeStorage.isEncryptionAvailable)) {417 if (!(yield* safeStorage.isEncryptionAvailable)) {
282 return Option.none<string>();418 return Option.none<string>();
283 }419 }
284 const records = yield* savedEnvironments.getRegistry;420 const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe(
285 if (records.length === 0) {421 Effect.flatMap(safeStorage.decryptString),
286 return Option.none<string>();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;
287 }428 }
288 const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments);429 yield* writeCatalog(catalog);
289 const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog);430 return true;
290 yield* writeCatalog(encoded);431 }),
291 return Option.some(encoded);432 clear: fileSystem.remove(catalogPath, { force: true }).pipe(
292 }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreMigrationError({ cause })));433 Effect.catch((error) =>
293434 Effect.logWarning("Could not clear the desktop connection catalog.", {
294 return DesktopConnectionCatalogStore.of({435 catalogPath,
295 get: Effect.gen(function* () {436 error,
296 const document = yield* readDocument(fileSystem, catalogPath).pipe(437 }),
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"),
325 ),438 ),
326 });439 Effect.withSpan("desktop.connectionCatalogStore.clear"),
327 }),440 ),
328);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";...@@ -8,11 +8,6 @@ import * as Schema from "effect/Schema";
88
9import * as DesktopConfig from "../app/DesktopConfig.ts";9import * as DesktopConfig from "../app/DesktopConfig.ts";
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import {
12 DEFAULT_DESKTOP_SETTINGS,
13 resolveDefaultDesktopSettings,
14 type DesktopSettings as DesktopSettingsValue,
15} from "./DesktopAppSettings.ts";
16import * as DesktopAppSettings from "./DesktopAppSettings.ts";11import * as DesktopAppSettings from "./DesktopAppSettings.ts";
1712
18const DesktopSettingsPatch = Schema.Struct({13const DesktopSettingsPatch = Schema.Struct({
...@@ -82,20 +77,23 @@ describe("DesktopSettings", () => {...@@ -82,20 +77,23 @@ describe("DesktopSettings", () => {
82 withSettings(77 withSettings(
83 Effect.gen(function* () {78 Effect.gen(function* () {
84 const settings = yield* DesktopAppSettings.DesktopAppSettings;79 const settings = yield* DesktopAppSettings.DesktopAppSettings;
85 assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);80 assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
86 assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS);81 assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
87 }),82 }),
88 ),83 ),
89 );84 );
9085
91 it("defaults packaged nightly builds to the nightly update channel", () => {86 it("defaults packaged nightly builds to the nightly update channel", () => {
92 assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), {87 assert.deepEqual(
93 serverExposureMode: "local-only",88 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
94 tailscaleServeEnabled: false,89 {
95 tailscaleServePort: 443,90 serverExposureMode: "local-only",
96 updateChannel: "nightly",91 tailscaleServeEnabled: false,
97 updateChannelConfiguredByUser: false,92 tailscaleServePort: 443,
98 } satisfies DesktopSettingsValue);93 updateChannel: "nightly",
94 updateChannelConfiguredByUser: false,
95 } satisfies DesktopAppSettings.DesktopSettings,
96 );
99 });97 });
10098
101 it.effect("loads persisted settings and applies semantic updates", () =>99 it.effect("loads persisted settings and applies semantic updates", () =>
...@@ -116,7 +114,7 @@ describe("DesktopSettings", () => {...@@ -116,7 +114,7 @@ describe("DesktopSettings", () => {
116 tailscaleServePort: 8443,114 tailscaleServePort: 8443,
117 updateChannel: "latest",115 updateChannel: "latest",
118 updateChannelConfiguredByUser: true,116 updateChannelConfiguredByUser: true,
119 } satisfies DesktopSettingsValue);117 } satisfies DesktopAppSettings.DesktopSettings);
120118
121 const exposure = yield* settings.setServerExposureMode("local-only");119 const exposure = yield* settings.setServerExposureMode("local-only");
122 assert.isTrue(exposure.changed);120 assert.isTrue(exposure.changed);
...@@ -137,6 +135,27 @@ describe("DesktopSettings", () => {...@@ -137,6 +135,27 @@ describe("DesktopSettings", () => {
137 ),135 ),
138 );136 );
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
140 it.effect("does not persist no-op semantic updates", () =>159 it.effect("does not persist no-op semantic updates", () =>
141 withSettings(160 withSettings(
142 Effect.gen(function* () {161 Effect.gen(function* () {
...@@ -167,7 +186,7 @@ describe("DesktopSettings", () => {...@@ -167,7 +186,7 @@ describe("DesktopSettings", () => {
167 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });186 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
168 yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json");187 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);
171 }),190 }),
172 ),191 ),
173 );192 );
...@@ -195,7 +214,7 @@ describe("DesktopSettings", () => {...@@ -195,7 +214,7 @@ describe("DesktopSettings", () => {
195 tailscaleServePort: 8443,214 tailscaleServePort: 8443,
196 updateChannel: "latest",215 updateChannel: "latest",
197 updateChannelConfiguredByUser: false,216 updateChannelConfiguredByUser: false,
198 } satisfies DesktopSettingsValue);217 } satisfies DesktopAppSettings.DesktopSettings);
199 }),218 }),
200 ),219 ),
201 );220 );
...@@ -234,7 +253,7 @@ describe("DesktopSettings", () => {...@@ -234,7 +253,7 @@ describe("DesktopSettings", () => {
234 tailscaleServePort: 443,253 tailscaleServePort: 443,
235 updateChannel: "nightly",254 updateChannel: "nightly",
236 updateChannelConfiguredByUser: false,255 updateChannelConfiguredByUser: false,
237 } satisfies DesktopSettingsValue);256 } satisfies DesktopAppSettings.DesktopSettings);
238 }),257 }),
239 { appVersion: "0.0.17-nightly.20260415.1" },258 { appVersion: "0.0.17-nightly.20260415.1" },
240 ),259 ),
...@@ -256,7 +275,7 @@ describe("DesktopSettings", () => {...@@ -256,7 +275,7 @@ describe("DesktopSettings", () => {
256 tailscaleServePort: 443,275 tailscaleServePort: 443,
257 updateChannel: "latest",276 updateChannel: "latest",
258 updateChannelConfiguredByUser: true,277 updateChannelConfiguredByUser: true,
259 } satisfies DesktopSettingsValue);278 } satisfies DesktopAppSettings.DesktopSettings);
260 }),279 }),
261 { appVersion: "0.0.17-nightly.20260415.1" },280 { appVersion: "0.0.17-nightly.20260415.1" },
262 ),281 ),
...@@ -277,7 +296,7 @@ describe("DesktopSettings", () => {...@@ -277,7 +296,7 @@ describe("DesktopSettings", () => {
277 tailscaleServePort: 443,296 tailscaleServePort: 443,
278 updateChannel: "latest",297 updateChannel: "latest",
279 updateChannelConfiguredByUser: false,298 updateChannelConfiguredByUser: false,
280 } satisfies DesktopSettingsValue);299 } satisfies DesktopAppSettings.DesktopSettings);
281 }),300 }),
282 ),301 ),
283 );302 );
apps/desktop/src/settings/DesktopAppSettings.ts+108-83
...@@ -7,13 +7,11 @@ import {...@@ -7,13 +7,11 @@ import {
7import { fromLenientJson } from "@t3tools/shared/schemaJson";7import { fromLenientJson } from "@t3tools/shared/schemaJson";
8import * as Context from "effect/Context";8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";9import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";13import * as Option from "effect/Option";
15import * as Path from "effect/Path";14import * as Path from "effect/Path";
16import * as PlatformError from "effect/PlatformError";
17import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
18import * as SynchronizedRef from "effect/SynchronizedRef";16import * as SynchronizedRef from "effect/SynchronizedRef";
1917
...@@ -63,32 +61,50 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet...@@ -63,32 +61,50 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet
63 changed,61 changed,
64});62});
6563
66export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{64const DesktopSettingsWriteOperation = Schema.Literals([
67 readonly cause: PlatformError.PlatformError | Schema.SchemaError;65 "create-temporary-file-name",
68}> {66 "encode-document",
69 override get message() {67 "create-directory",
70 return `Failed to write desktop settings: ${this.cause.message}`;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}.`;
71 }83 }
72}84}
7385
74export interface DesktopAppSettingsShape {86const writeError = (
75 readonly load: Effect.Effect<DesktopSettings>;87 operation: DesktopSettingsWriteOperation,
76 readonly get: Effect.Effect<DesktopSettings>;88 path: string,
77 readonly setServerExposureMode: (89 cause: unknown,
78 mode: DesktopServerExposureMode,90): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause });
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}
8891
89export class DesktopAppSettings extends Context.Service<92export class DesktopAppSettings extends Context.Service<
90 DesktopAppSettings,93 DesktopAppSettings,
91 DesktopAppSettingsShape94 {
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 }
92>()("@t3tools/desktop/settings/DesktopAppSettings") {}108>()("@t3tools/desktop/settings/DesktopAppSettings") {}
93109
94export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {110export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
...@@ -223,77 +239,86 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp...@@ -223,77 +239,86 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
223 readonly settings: DesktopSettings;239 readonly settings: DesktopSettings;
224 readonly defaultSettings: DesktopSettings;240 readonly defaultSettings: DesktopSettings;
225 readonly suffix: string;241 readonly suffix: string;
226}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {242}): Effect.fn.Return<void, DesktopSettingsWriteError> {
227 const directory = input.path.dirname(input.settingsPath);243 const directory = input.path.dirname(input.settingsPath);
228 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;244 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
229 const encoded = yield* encodeDesktopSettingsJson(245 const encoded = yield* encodeDesktopSettingsJson(
230 toDesktopSettingsDocument(input.settings, input.defaultSettings),246 toDesktopSettingsDocument(input.settings, input.defaultSettings),
231 );247 ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)));
232 yield* input.fileSystem.makeDirectory(directory, { recursive: true });248 yield* input.fileSystem
233 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);249 .makeDirectory(directory, { recursive: true })
234 yield* input.fileSystem.rename(tempPath, input.settingsPath);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 );
235});259});
236260
237export const layer = Layer.effect(261export const make = Effect.gen(function* () {
238 DesktopAppSettings,262 const environment = yield* DesktopEnvironment.DesktopEnvironment;
239 Effect.gen(function* () {263 const fileSystem = yield* FileSystem.FileSystem;
240 const environment = yield* DesktopEnvironment.DesktopEnvironment;264 const path = yield* Path.Path;
241 const fileSystem = yield* FileSystem.FileSystem;265 const crypto = yield* Crypto.Crypto;
242 const path = yield* Path.Path;266 const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
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 });
271267
272 return DesktopAppSettings.of({268 const persist = (
273 get: SynchronizedRef.get(settingsRef),269 update: (settings: DesktopSettings) => DesktopSettings,
274 load: Effect.gen(function* () {270 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
275 const settings = yield* readSettings(271 SynchronizedRef.modifyEffect(settingsRef, (settings) => {
276 fileSystem,272 const nextSettings = update(settings);
277 environment.desktopSettingsPath,273 if (nextSettings === settings) {
278 environment.appVersion,274 return Effect.succeed([settingsChange(settings, false), settings] as const);
279 );275 }
280 return yield* SynchronizedRef.setAndGet(settingsRef, settings);276
281 }).pipe(Effect.withSpan("desktop.settings.load")),277 return crypto.randomUUIDv4.pipe(
282 setServerExposureMode: (mode) =>278 Effect.map((uuid) => uuid.replace(/-/g, "")),
283 persist((settings) => setServerExposureMode(settings, mode)).pipe(279 Effect.mapError((cause) =>
284 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),280 writeError("create-temporary-file-name", environment.desktopSettingsPath, cause),
285 ),
286 setTailscaleServe: (input) =>
287 persist((settings) => setTailscaleServe(settings, input)).pipe(
288 Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }),
289 ),281 ),
290 setUpdateChannel: (channel) =>282 Effect.flatMap((suffix) =>
291 persist((settings) => setUpdateChannel(settings, channel)).pipe(283 writeSettings({
292 Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),284 fileSystem,
285 path,
286 settingsPath: environment.desktopSettingsPath,
287 settings: nextSettings,
288 defaultSettings: environment.defaultDesktopSettings,
289 suffix,
290 }),
293 ),291 ),
292 Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
293 );
294 });294 });
295 }),295
296);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
298export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>323export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>
299 Layer.effect(324 Layer.effect(
apps/desktop/src/settings/DesktopClientSettings.test.ts+21-1
...@@ -34,7 +34,6 @@ const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(Clien...@@ -34,7 +34,6 @@ const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(Clien
34const decodeRecordJson = Schema.decodeEffect(34const decodeRecordJson = Schema.decodeEffect(
35 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),35 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
36);36);
37
38function makeLayer(baseDir: string) {37function makeLayer(baseDir: string) {
39 const environmentLayer = DesktopEnvironment.layer({38 const environmentLayer = DesktopEnvironment.layer({
40 dirname: "/repo/apps/desktop/src",39 dirname: "/repo/apps/desktop/src",
...@@ -106,6 +105,27 @@ describe("DesktopClientSettings", () => {...@@ -106,6 +105,27 @@ describe("DesktopClientSettings", () => {
106 ),105 ),
107 );106 );
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
109 it.effect("loads lenient direct client settings documents", () =>129 it.effect("loads lenient direct client settings documents", () =>
110 withClientSettings(130 withClientSettings(
111 Effect.gen(function* () {131 Effect.gen(function* () {
apps/desktop/src/settings/DesktopClientSettings.ts+74-46
...@@ -2,13 +2,11 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";...@@ -2,13 +2,11 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
2import { fromLenientJson } from "@t3tools/shared/schemaJson";2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";8import * as Option from "effect/Option";
10import * as Path from "effect/Path";9import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
13import * as Ref from "effect/Ref";11import * as Ref from "effect/Ref";
1412
...@@ -31,24 +29,43 @@ const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Sc...@@ -31,24 +29,43 @@ const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Sc
31 );29 );
32const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);30const 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>()(
35 "DesktopClientSettingsWriteError",42 "DesktopClientSettingsWriteError",
36)<{43 {
37 readonly cause: PlatformError.PlatformError | Schema.SchemaError;44 operation: DesktopClientSettingsWriteOperation,
38}> {45 path: Schema.String,
39 override get message() {46 cause: Schema.Defect(),
40 return `Failed to write desktop client settings: ${this.cause.message}`;47 },
48) {
49 override get message(): string {
50 return `Desktop client settings write failed during ${this.operation} at ${this.path}.`;
41 }51 }
42}52}
4353
44export interface DesktopClientSettingsShape {54const writeError = (
45 readonly get: Effect.Effect<Option.Option<ClientSettings>>;55 operation: DesktopClientSettingsWriteOperation,
46 readonly set: (settings: ClientSettings) => Effect.Effect<void, DesktopClientSettingsWriteError>;56 path: string,
47}57 cause: unknown,
58): DesktopClientSettingsWriteError =>
59 new DesktopClientSettingsWriteError({ operation, path, cause });
4860
49export class DesktopClientSettings extends Context.Service<61export class DesktopClientSettings extends Context.Service<
50 DesktopClientSettings,62 DesktopClientSettings,
51 DesktopClientSettingsShape63 {
64 readonly get: Effect.Effect<Option.Option<ClientSettings>>;
65 readonly set: (
66 settings: ClientSettings,
67 ) => Effect.Effect<void, DesktopClientSettingsWriteError>;
68 }
52>()("@t3tools/desktop/settings/DesktopClientSettings") {}69>()("@t3tools/desktop/settings/DesktopClientSettings") {}
5370
54const readClientSettings = (71const readClientSettings = (
...@@ -75,45 +92,56 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {...@@ -75,45 +92,56 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
75 readonly settingsPath: string;92 readonly settingsPath: string;
76 readonly settings: ClientSettings;93 readonly settings: ClientSettings;
77 readonly suffix: string;94 readonly suffix: string;
78}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {95}): Effect.fn.Return<void, DesktopClientSettingsWriteError> {
79 const directory = input.path.dirname(input.settingsPath);96 const directory = input.path.dirname(input.settingsPath);
80 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;97 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
81 const encoded = yield* encodeClientSettingsJson(input.settings);98 const encoded = yield* encodeClientSettingsJson(input.settings).pipe(
82 yield* input.fileSystem.makeDirectory(directory, { recursive: true });99 Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)),
83 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);100 );
84 yield* input.fileSystem.rename(tempPath, input.settingsPath);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 );
85});112});
86113
87export const layer = Layer.effect(114export const make = Effect.gen(function* () {
88 DesktopClientSettings,115 const environment = yield* DesktopEnvironment.DesktopEnvironment;
89 Effect.gen(function* () {116 const fileSystem = yield* FileSystem.FileSystem;
90 const environment = yield* DesktopEnvironment.DesktopEnvironment;117 const path = yield* Path.Path;
91 const fileSystem = yield* FileSystem.FileSystem;118 const crypto = yield* Crypto.Crypto;
92 const path = yield* Path.Path;
93 const crypto = yield* Crypto.Crypto;
94119
95 return DesktopClientSettings.of({120 return DesktopClientSettings.of({
96 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(121 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
97 Effect.withSpan("desktop.clientSettings.get"),122 Effect.withSpan("desktop.clientSettings.get"),
98 ),123 ),
99 set: (settings) =>124 set: (settings) =>
100 crypto.randomUUIDv4.pipe(125 crypto.randomUUIDv4.pipe(
101 Effect.map((uuid) => uuid.replace(/-/g, "")),126 Effect.map((uuid) => uuid.replace(/-/g, "")),
102 Effect.flatMap((suffix) =>127 Effect.mapError((cause) =>
103 writeClientSettings({128 writeError("create-temporary-file-name", environment.clientSettingsPath, cause),
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"),
113 ),129 ),
114 });130 Effect.flatMap((suffix) =>
115 }),131 writeClientSettings({
116);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
118export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>146export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>
119 Layer.effect(147 Layer.effect(
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+138-16
...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
8import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
910
10import * as DesktopConfig from "../app/DesktopConfig.ts";11import * as DesktopConfig from "../app/DesktopConfig.ts";
...@@ -34,10 +35,15 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({...@@ -34,10 +35,15 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
34 version: Schema.Number,35 version: Schema.Number,
35 records: Schema.Array(Schema.Unknown),36 records: Schema.Array(Schema.Unknown),
36});37});
38const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(
39 SavedEnvironmentRegistryDocumentProbe,
40);
37const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(41const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
38 Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe),42 SavedEnvironmentRegistryDocumentProbeJson,
43);
44const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(
45 SavedEnvironmentRegistryDocumentProbeJson,
39);46);
40
41function makeSafeStorageLayer(input: {47function makeSafeStorageLayer(input: {
42 readonly available: boolean;48 readonly available: boolean;
43 readonly availabilityError?: unknown;49 readonly availabilityError?: unknown;
...@@ -80,7 +86,7 @@ function makeSafeStorageLayer(input: {...@@ -80,7 +86,7 @@ function makeSafeStorageLayer(input: {
80 }86 }
81 return Effect.succeed(decoded.slice("enc:".length));87 return Effect.succeed(decoded.slice("enc:".length));
82 },88 },
83 } satisfies ElectronSafeStorage.ElectronSafeStorageShape);89 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
84}90}
8591
86function makeLayer(92function makeLayer(
...@@ -91,6 +97,7 @@ function makeLayer(...@@ -91,6 +97,7 @@ function makeLayer(
91 readonly encryptError?: unknown;97 readonly encryptError?: unknown;
92 readonly decryptError?: unknown;98 readonly decryptError?: unknown;
93 },99 },
100 fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,
94) {101) {
95 const environmentLayer = DesktopEnvironment.layer({102 const environmentLayer = DesktopEnvironment.layer({
96 dirname: "/repo/apps/desktop/src",103 dirname: "/repo/apps/desktop/src",
...@@ -108,18 +115,20 @@ function makeLayer(...@@ -108,18 +115,20 @@ function makeLayer(
108 ),115 ),
109 );116 );
110117
111 return DesktopSavedEnvironments.layer.pipe(118 const safeStorageLayer = makeSafeStorageLayer({
112 Layer.provideMerge(environmentLayer),119 available: options?.availableSecretStorage ?? true,
113 Layer.provideMerge(120 availabilityError: options?.availabilityError,
114 makeSafeStorageLayer({121 encryptError: options?.encryptError,
115 available: options?.availableSecretStorage ?? true,122 decryptError: options?.decryptError,
116 availabilityError: options?.availabilityError,123 });
117 encryptError: options?.encryptError,124 const dependencies = Layer.mergeAll(
118 decryptError: options?.decryptError,125 environmentLayer,
119 }),126 safeStorageLayer,
120 ),127 NodeServices.layer,
121 Layer.provideMerge(NodeServices.layer),128 fileSystemLayer,
122 );129 );
130
131 return DesktopSavedEnvironments.layer.pipe(Layer.provideMerge(dependencies));
123}132}
124133
125const withSavedEnvironments = <A, E, R>(134const withSavedEnvironments = <A, E, R>(
...@@ -215,6 +224,37 @@ describe("DesktopSavedEnvironments", () => {...@@ -215,6 +224,37 @@ describe("DesktopSavedEnvironments", () => {
215 ),224 ),
216 );225 );
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
218 it.effect("returns false when writing secrets while encryption is unavailable", () =>258 it.effect("returns false when writing secrets while encryption is unavailable", () =>
219 withSavedEnvironments(259 withSavedEnvironments(
220 Effect.gen(function* () {260 Effect.gen(function* () {
...@@ -321,16 +361,98 @@ describe("DesktopSavedEnvironments", () => {...@@ -321,16 +361,98 @@ describe("DesktopSavedEnvironments", () => {
321 const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip);361 const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip);
322 assert.instanceOf(362 assert.instanceOf(
323 registryError,363 registryError,
324 DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError,364 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
325 );365 );
366 assert.equal(registryError.operation, "decode-registry");
367 assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath);
368 assert.exists(registryError.cause);
326 const secretError = yield* savedEnvironments369 const secretError = yield* savedEnvironments
327 .getSecret(savedRegistryRecord.environmentId)370 .getSecret(savedRegistryRecord.environmentId)
328 .pipe(Effect.flip);371 .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 );
330 }),383 }),
331 ),384 ),
332 );385 );
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
334 it.effect("returns false when writing a secret without metadata", () =>456 it.effect("returns false when writing a secret without metadata", () =>
335 withSavedEnvironments(457 withSavedEnvironments(
336 Effect.gen(function* () {458 Effect.gen(function* () {
apps/desktop/src/settings/DesktopSavedEnvironments.ts+260-176
...@@ -2,14 +2,12 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co...@@ -2,14 +2,12 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co
2import { fromLenientJson } from "@t3tools/shared/schemaJson";2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
7import * as Encoding from "effect/Encoding";6import * as Encoding from "effect/Encoding";
8import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";9import * as Option from "effect/Option";
11import * as Path from "effect/Path";10import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
14import * as Ref from "effect/Ref";12import * as Ref from "effect/Ref";
1513
...@@ -72,73 +70,123 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(...@@ -72,73 +70,123 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(
72 SavedEnvironmentRegistryDocumentJson,70 SavedEnvironmentRegistryDocumentJson,
73);71);
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>()(
76 "DesktopSavedEnvironmentsWriteError",83 "DesktopSavedEnvironmentsWriteError",
77)<{84 {
78 readonly cause: PlatformError.PlatformError | Schema.SchemaError;85 operation: DesktopSavedEnvironmentsWriteOperation,
79}> {86 path: Schema.String,
80 override get message() {87 cause: Schema.Defect(),
81 return `Failed to write desktop saved environments: ${this.cause.message}`;88 },
89) {
90 override get message(): string {
91 return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`;
82 }92 }
83}93}
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>()(
86 "DesktopSavedEnvironmentsReadError",107 "DesktopSavedEnvironmentsReadError",
87)<{108 {
88 readonly cause: PlatformError.PlatformError | Schema.SchemaError;109 operation: Schema.Literal("read-registry"),
89}> {110 registryPath: Schema.String,
90 override get message() {111 cause: Schema.Defect(),
91 return `Failed to read desktop saved environments: ${this.cause.message}`;112 },
113) {
114 override get message(): string {
115 return `Failed to read desktop saved environments at ${this.registryPath}.`;
92 }116 }
93}117}
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>()(
96 "DesktopSavedEnvironmentSecretDecodeError",133 "DesktopSavedEnvironmentSecretDecodeError",
97)<{134 {
98 readonly cause: Encoding.EncodingError;135 operation: Schema.Literal("decode-secret"),
99}> {136 environmentId: Schema.String,
100 override get message() {137 registryPath: Schema.String,
101 return "Failed to decode desktop saved environment secret.";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}.`;
102 }144 }
103}145}
104146
105export type DesktopSavedEnvironmentsGetSecretError =147export type DesktopSavedEnvironmentsReadRegistryError =
106 | DesktopSavedEnvironmentsReadError148 | DesktopSavedEnvironmentsReadError
149 | DesktopSavedEnvironmentsDocumentDecodeError;
150
151export type DesktopSavedEnvironmentsMutationError =
152 | DesktopSavedEnvironmentsReadRegistryError
153 | DesktopSavedEnvironmentsWriteError;
154
155export type DesktopSavedEnvironmentsGetSecretError =
156 | DesktopSavedEnvironmentsReadRegistryError
107 | DesktopSavedEnvironmentSecretDecodeError157 | DesktopSavedEnvironmentSecretDecodeError
108 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError158 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
109 | ElectronSafeStorage.ElectronSafeStorageDecryptError;159 | ElectronSafeStorage.ElectronSafeStorageDecryptError;
110160
111export type DesktopSavedEnvironmentsSetSecretError =161export type DesktopSavedEnvironmentsSetSecretError =
112 | DesktopSavedEnvironmentsWriteError162 | DesktopSavedEnvironmentsMutationError
113 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError163 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
114 | ElectronSafeStorage.ElectronSafeStorageEncryptError;164 | 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
139export class DesktopSavedEnvironments extends Context.Service<166export class DesktopSavedEnvironments extends Context.Service<
140 DesktopSavedEnvironments,167 DesktopSavedEnvironments,
141 DesktopSavedEnvironmentsShape168 {
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 }
142>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}190>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
143191
144function toPersistedSavedEnvironmentRecord(192function toPersistedSavedEnvironmentRecord(
...@@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument(...@@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument(
193function readRegistryDocument(241function readRegistryDocument(
194 fileSystem: FileSystem.FileSystem,242 fileSystem: FileSystem.FileSystem,
195 registryPath: string,243 registryPath: string,
196): Effect.Effect<244): Effect.Effect<SavedEnvironmentRegistryDocument, DesktopSavedEnvironmentsReadRegistryError> {
197 SavedEnvironmentRegistryDocument,
198 PlatformError.PlatformError | Schema.SchemaError
199> {
200 return fileSystem.readFileString(registryPath).pipe(245 return fileSystem.readFileString(registryPath).pipe(
201 Effect.catch((error) =>246 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 ),
203 ),256 ),
204 Effect.flatMap((raw) =>257 Effect.flatMap((raw) =>
205 raw === null258 raw === null
206 ? Effect.succeed({ version: 1, records: [] })259 ? Effect.succeed({ version: 1, records: [] })
207 : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(260 : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(
208 Effect.map(normalizeSavedEnvironmentRegistryDocument),261 Effect.map(normalizeSavedEnvironmentRegistryDocument),
262 Effect.mapError(
263 (cause) =>
264 new DesktopSavedEnvironmentsDocumentDecodeError({
265 operation: "decode-registry",
266 registryPath,
267 cause,
268 }),
269 ),
209 ),270 ),
210 ),271 ),
211 );272 );
...@@ -218,13 +279,23 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry...@@ -218,13 +279,23 @@ const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistry
218 readonly registryPath: string;279 readonly registryPath: string;
219 readonly document: SavedEnvironmentRegistryDocument;280 readonly document: SavedEnvironmentRegistryDocument;
220 readonly suffix: string;281 readonly suffix: string;
221 }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {282 }): Effect.fn.Return<void, DesktopSavedEnvironmentsWriteError> {
222 const directory = input.path.dirname(input.registryPath);283 const directory = input.path.dirname(input.registryPath);
223 const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;284 const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;
224 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document);285 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe(
225 yield* input.fileSystem.makeDirectory(directory, { recursive: true });286 Effect.mapError((cause) => writeError("encode-registry", input.registryPath, cause)),
226 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);287 );
227 yield* input.fileSystem.rename(tempPath, input.registryPath);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 );
228 },299 },
229);300);
230301
...@@ -250,147 +321,160 @@ function preserveExistingSecrets(...@@ -250,147 +321,160 @@ function preserveExistingSecrets(
250}321}
251322
252function decodeSecretBytes(323function decodeSecretBytes(
324 environmentId: string,
325 registryPath: string,
253 encoded: string,326 encoded: string,
254): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {327): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {
255 return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(328 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 ),
257 );339 );
258}340}
259341
260export const layer = Layer.effect(342export const make = Effect.gen(function* () {
261 DesktopSavedEnvironments,343 const environment = yield* DesktopEnvironment.DesktopEnvironment;
262 Effect.gen(function* () {344 const fileSystem = yield* FileSystem.FileSystem;
263 const environment = yield* DesktopEnvironment.DesktopEnvironment;345 const path = yield* Path.Path;
264 const fileSystem = yield* FileSystem.FileSystem;346 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
265 const path = yield* Path.Path;347 const crypto = yield* Crypto.Crypto;
266 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;348
267 const crypto = yield* Crypto.Crypto;349 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
268350 crypto.randomUUIDv4.pipe(
269 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>351 Effect.map((uuid) => uuid.replace(/-/g, "")),
270 crypto.randomUUIDv4.pipe(352 Effect.mapError((cause) =>
271 Effect.map((uuid) => uuid.replace(/-/g, "")),353 writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause),
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"),
291 ),354 ),
292 setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {355 Effect.flatMap((suffix) =>
293 const currentDocument = yield* readRegistryDocument(356 writeRegistryDocument({
294 fileSystem,357 fileSystem,
295 environment.savedEnvironmentRegistryPath,358 path,
296 ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));359 registryPath: environment.savedEnvironmentRegistryPath,
297 yield* writeDocument(preserveExistingSecrets(currentDocument, records));360 document,
298 }),361 suffix,
299 removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")(362 }),
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 },
315 ),363 ),
316 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {364 );
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 }
329365
330 const secretBytes = yield* decodeSecretBytes(encoded.value);366 return DesktopSavedEnvironments.of({
331 return Option.some(yield* safeStorage.decryptString(secretBytes));367 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
332 }),368 Effect.map((document) =>
333 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {369 document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
334 const { environmentId, secret } = input;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) {
335 yield* Effect.annotateCurrentSpan({ environmentId });382 yield* Effect.annotateCurrentSpan({ environmentId });
336 const document = yield* readRegistryDocument(383 const document = yield* readRegistryDocument(
337 fileSystem,384 fileSystem,
338 environment.savedEnvironmentRegistryPath,385 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),
347 );386 );
348 let found = false;387 if (!document.records.some((record) => record.environmentId === environmentId)) {
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 ) {
378 return;388 return;
379 }389 }
380390
381 yield* writeDocument({391 yield* writeDocument({
382 version: document.version,392 version: document.version,
383 records: document.records.map((record) => {393 records: document.records.filter((record) => record.environmentId !== environmentId),
384 if (record.environmentId !== environmentId) {
385 return record;
386 }
387 return toPersistedSavedEnvironmentRecord(record);
388 }),
389 });394 });
390 }),395 },
391 });396 ),
392 }),397 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
393);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
395export const layerTest = (input?: {479export const layerTest = (input?: {
396 readonly records?: readonly PersistedSavedEnvironmentRecord[];480 readonly records?: readonly PersistedSavedEnvironmentRecord[];