| author | |
| committer | |
| log | 65385c028169da7e18d0f25124f0e3f6a1208567 |
| tree | b0484a6c56a5e731f553a77ce4d88d47d6fb2503 |
| parent | 6d2dae0627897e84e79bc7d2f164a564357fe9b0 |
| signature | Signed by PGP key B5690EEEBB952194 |
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(); |
| 21 | const decodeConnectionCatalog = Schema.decodeEffect( | 21 | const decodeConnectionCatalog = Schema.decodeEffect( |
| 22 | Schema.fromJsonString(ConnectionCatalogDocument), | 22 | Schema.fromJsonString(ConnectionCatalogDocument), |
| 23 | ); | 23 | ); |
| 24 | |||
| 25 | function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) { | 24 | function 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 | } |
| 45 | 44 | ||
| 46 | function makeLayer( | 45 | function 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 | ); |
| 276 | 285 | ||
| 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"; |
| 14 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 14 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 15 | import * as Context from "effect/Context"; | 15 | import * as Context from "effect/Context"; |
| 16 | import * as Crypto from "effect/Crypto"; | 16 | import * as Crypto from "effect/Crypto"; |
| 17 | import * as Data from "effect/Data"; | ||
| 18 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 19 | import * as Encoding from "effect/Encoding"; | 18 | import * as Encoding from "effect/Encoding"; |
| 20 | import * as FileSystem from "effect/FileSystem"; | 19 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Layer from "effect/Layer"; | 20 | import * as Layer from "effect/Layer"; |
| 22 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 23 | import * as Path from "effect/Path"; | 22 | import * as Path from "effect/Path"; |
| 24 | import * as PlatformError from "effect/PlatformError"; | ||
| 25 | import * as Schema from "effect/Schema"; | 23 | import * as Schema from "effect/Schema"; |
| 26 | 24 | ||
| 27 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | 25 | import * 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 | ); |
| 50 | 48 | ||
| 51 | export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError( | 49 | const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ |
| 50 | "create-temporary-file-name", | ||
| 51 | "encode-document", | ||
| 52 | "create-directory", | ||
| 53 | "write-temporary-file", | ||
| 54 | "replace-catalog-file", | ||
| 55 | ]); | ||
| 56 | type DesktopConnectionCatalogStoreWriteOperation = | ||
| 57 | typeof DesktopConnectionCatalogStoreWriteOperation.Type; | ||
| 58 | |||
| 59 | const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ | ||
| 60 | "read-legacy-registry", | ||
| 61 | "read-legacy-secret", | ||
| 62 | "encode-catalog", | ||
| 63 | "persist-catalog", | ||
| 64 | ]); | ||
| 65 | type DesktopConnectionCatalogStoreMigrationOperation = | ||
| 66 | typeof DesktopConnectionCatalogStoreMigrationOperation.Type; | ||
| 67 | |||
| 68 | export 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 | } |
| 60 | 80 | ||
| 61 | export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError( | 81 | const writeError = ( |
| 82 | operation: DesktopConnectionCatalogStoreWriteOperation, | ||
| 83 | path: string, | ||
| 84 | cause: unknown, | ||
| 85 | ): DesktopConnectionCatalogStoreWriteError => | ||
| 86 | new DesktopConnectionCatalogStoreWriteError({ | ||
| 87 | operation, | ||
| 88 | path, | ||
| 89 | cause, | ||
| 90 | }); | ||
| 91 | |||
| 92 | export 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 | } |
| 70 | 105 | ||
| 71 | export class DesktopConnectionCatalogStoreReadError extends Data.TaggedError( | 106 | export 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 | } |
| 80 | 118 | ||
| 81 | export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError( | 119 | export 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 | } |
| 90 | 131 | ||
| 91 | export interface DesktopConnectionCatalogStoreShape { | 132 | export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass<DesktopConnectionCatalogStoreMigrationError>()( |
| 92 | readonly get: Effect.Effect< | 133 | "DesktopConnectionCatalogStoreMigrationError", |
| 93 | Option.Option<string>, | 134 | { |
| 94 | | DesktopConnectionCatalogStoreReadError | 135 | operation: DesktopConnectionCatalogStoreMigrationOperation, |
| 95 | | DesktopConnectionCatalogStoreDecodeError | 136 | catalogPath: Schema.String, |
| 96 | | DesktopConnectionCatalogStoreMigrationError | 137 | environmentId: Schema.optionalKey(Schema.String), |
| 97 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | 138 | cause: Schema.Defect(), |
| 98 | | ElectronSafeStorage.ElectronSafeStorageDecryptError | 139 | }, |
| 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 | | DesktopConnectionCatalogStoreWriteError | 145 | } |
| 105 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ||
| 106 | | ElectronSafeStorage.ElectronSafeStorageEncryptError | ||
| 107 | >; | ||
| 108 | readonly clear: Effect.Effect<void>; | ||
| 109 | } | 146 | } |
| 110 | 147 | ||
| 148 | const 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 | |||
| 111 | export class DesktopConnectionCatalogStore extends Context.Service< | 161 | export class DesktopConnectionCatalogStore extends Context.Service< |
| 112 | DesktopConnectionCatalogStore, | 162 | 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 | } | ||
| 114 | >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} | 183 | >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} |
| 115 | 184 | ||
| 116 | function decodeSecretBytes( | 185 | function 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 | } |
| 123 | 201 | ||
| ... | @@ -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.SchemaError | 207 | 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 === null | 222 | 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 | ); |
| 141 | 237 | ||
| ... | @@ -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.DesktopSavedEnvironmentsGetSecretError | 288 | 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 | }); |
| 252 | 365 | ||
| 253 | export const layer = Layer.effect( | 366 | export 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"); | ||
| 263 | 374 | ||
| 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 | }); | ||
| 279 | 390 | ||
| 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) => |
| 293 | 434 | 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 | |||
| 444 | export 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"; |
| 8 | 8 | ||
| 9 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 9 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 10 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 10 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 11 | import { | ||
| 12 | DEFAULT_DESKTOP_SETTINGS, | ||
| 13 | resolveDefaultDesktopSettings, | ||
| 14 | type DesktopSettings as DesktopSettingsValue, | ||
| 15 | } from "./DesktopAppSettings.ts"; | ||
| 16 | import * as DesktopAppSettings from "./DesktopAppSettings.ts"; | 11 | import * as DesktopAppSettings from "./DesktopAppSettings.ts"; |
| 17 | 12 | ||
| 18 | const DesktopSettingsPatch = Schema.Struct({ | 13 | const 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 | ); |
| 90 | 85 | ||
| 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 | }); |
| 100 | 98 | ||
| 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); |
| 120 | 118 | ||
| 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 | ); |
| 139 | 137 | ||
| 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"); |
| 169 | 188 | ||
| 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 { |
| 7 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 7 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 8 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Data from "effect/Data"; | ||
| 11 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 12 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 15 | import * as Path from "effect/Path"; | 14 | import * as Path from "effect/Path"; |
| 16 | import * as PlatformError from "effect/PlatformError"; | ||
| 17 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 18 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 16 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 19 | 17 | ||
| ... | @@ -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 | }); |
| 65 | 63 | ||
| 66 | export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ | 64 | const 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 | ]); | ||
| 71 | type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type; | ||
| 72 | |||
| 73 | export 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 | } |
| 73 | 85 | ||
| 74 | export interface DesktopAppSettingsShape { | 86 | const 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 | } | ||
| 88 | 91 | ||
| 89 | export class DesktopAppSettings extends Context.Service< | 92 | export class DesktopAppSettings extends Context.Service< |
| 90 | DesktopAppSettings, | 93 | 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 | } | ||
| 92 | >()("@t3tools/desktop/settings/DesktopAppSettings") {} | 108 | >()("@t3tools/desktop/settings/DesktopAppSettings") {} |
| 93 | 109 | ||
| 94 | export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { | 110 | export 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 | }); |
| 236 | 260 | ||
| 237 | export const layer = Layer.effect( | 261 | export 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 | }); | ||
| 271 | 267 | ||
| 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 | |||
| 321 | export const layer = Layer.effect(DesktopAppSettings, make); | ||
| 297 | 322 | ||
| 298 | export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => | 323 | export 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 |
| 34 | const decodeRecordJson = Schema.decodeEffect( | 34 | const decodeRecordJson = Schema.decodeEffect( |
| 35 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | 35 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), |
| 36 | ); | 36 | ); |
| 37 | |||
| 38 | function makeLayer(baseDir: string) { | 37 | function 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 | ); |
| 108 | 107 | ||
| 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"; |
| 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 3 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 4 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; | 9 | import * as Path from "effect/Path"; |
| 11 | import * as PlatformError from "effect/PlatformError"; | ||
| 12 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 13 | import * as Ref from "effect/Ref"; | 11 | import * as Ref from "effect/Ref"; |
| 14 | 12 | ||
| ... | @@ -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 | ); |
| 32 | const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); | 30 | const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); |
| 33 | 31 | ||
| 34 | export class DesktopClientSettingsWriteError extends Data.TaggedError( | 32 | const DesktopClientSettingsWriteOperation = Schema.Literals([ |
| 33 | "create-temporary-file-name", | ||
| 34 | "encode-document", | ||
| 35 | "create-directory", | ||
| 36 | "write-temporary-file", | ||
| 37 | "replace-settings-file", | ||
| 38 | ]); | ||
| 39 | type DesktopClientSettingsWriteOperation = typeof DesktopClientSettingsWriteOperation.Type; | ||
| 40 | |||
| 41 | export 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 | } |
| 43 | 53 | ||
| 44 | export interface DesktopClientSettingsShape { | 54 | const 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 }); | ||
| 48 | 60 | ||
| 49 | export class DesktopClientSettings extends Context.Service< | 61 | export class DesktopClientSettings extends Context.Service< |
| 50 | DesktopClientSettings, | 62 | DesktopClientSettings, |
| 51 | DesktopClientSettingsShape | 63 | { |
| 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") {} |
| 53 | 70 | ||
| 54 | const readClientSettings = ( | 71 | const 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 | }); |
| 86 | 113 | ||
| 87 | export const layer = Layer.effect( | 114 | export 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; | ||
| 94 | 119 | ||
| 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 | |||
| 144 | export const layer = Layer.effect(DesktopClientSettings, make); | ||
| 117 | 145 | ||
| 118 | export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) => | 146 | export 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"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 9 | 10 | ||
| 10 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 11 | import * 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 | }); |
| 38 | const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString( | ||
| 39 | SavedEnvironmentRegistryDocumentProbe, | ||
| 40 | ); | ||
| 37 | const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( | 41 | const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( |
| 38 | Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), | 42 | SavedEnvironmentRegistryDocumentProbeJson, |
| 43 | ); | ||
| 44 | const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect( | ||
| 45 | SavedEnvironmentRegistryDocumentProbeJson, | ||
| 39 | ); | 46 | ); |
| 40 | |||
| 41 | function makeSafeStorageLayer(input: { | 47 | function 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 | } |
| 85 | 91 | ||
| 86 | function makeLayer( | 92 | function 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 | ); |
| 110 | 117 | ||
| 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 | } |
| 124 | 133 | ||
| 125 | const withSavedEnvironments = <A, E, R>( | 134 | const withSavedEnvironments = <A, E, R>( |
| ... | @@ -215,6 +224,37 @@ describe("DesktopSavedEnvironments", () => { | ... | @@ -215,6 +224,37 @@ describe("DesktopSavedEnvironments", () => { |
| 215 | ), | 224 | ), |
| 216 | ); | 225 | ); |
| 217 | 226 | ||
| 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* savedEnvironments | 369 | 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 | ); |
| 333 | 386 | ||
| 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 |
| 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 3 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 4 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 7 | import * as Encoding from "effect/Encoding"; | 6 | import * as Encoding from "effect/Encoding"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 12 | import * as PlatformError from "effect/PlatformError"; | ||
| 13 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 14 | import * as Ref from "effect/Ref"; | 12 | import * as Ref from "effect/Ref"; |
| 15 | 13 | ||
| ... | @@ -72,73 +70,123 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( | ... | @@ -72,73 +70,123 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( |
| 72 | SavedEnvironmentRegistryDocumentJson, | 70 | SavedEnvironmentRegistryDocumentJson, |
| 73 | ); | 71 | ); |
| 74 | 72 | ||
| 75 | export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( | 73 | const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ |
| 74 | "create-temporary-file-name", | ||
| 75 | "encode-registry", | ||
| 76 | "create-directory", | ||
| 77 | "write-temporary-file", | ||
| 78 | "replace-registry-file", | ||
| 79 | ]); | ||
| 80 | type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type; | ||
| 81 | |||
| 82 | export 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 | } |
| 84 | 94 | ||
| 85 | export class DesktopSavedEnvironmentsReadError extends Data.TaggedError( | 95 | const writeError = ( |
| 96 | operation: DesktopSavedEnvironmentsWriteOperation, | ||
| 97 | path: string, | ||
| 98 | cause: unknown, | ||
| 99 | ): DesktopSavedEnvironmentsWriteError => | ||
| 100 | new DesktopSavedEnvironmentsWriteError({ | ||
| 101 | operation, | ||
| 102 | path, | ||
| 103 | cause, | ||
| 104 | }); | ||
| 105 | |||
| 106 | export 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 | } |
| 94 | 118 | ||
| 95 | export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( | 119 | export 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 | |||
| 132 | export 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 | } |
| 104 | 146 | ||
| 105 | export type DesktopSavedEnvironmentsGetSecretError = | 147 | export type DesktopSavedEnvironmentsReadRegistryError = |
| 106 | | DesktopSavedEnvironmentsReadError | 148 | | DesktopSavedEnvironmentsReadError |
| 149 | | DesktopSavedEnvironmentsDocumentDecodeError; | ||
| 150 | |||
| 151 | export type DesktopSavedEnvironmentsMutationError = | ||
| 152 | | DesktopSavedEnvironmentsReadRegistryError | ||
| 153 | | DesktopSavedEnvironmentsWriteError; | ||
| 154 | |||
| 155 | export type DesktopSavedEnvironmentsGetSecretError = | ||
| 156 | | DesktopSavedEnvironmentsReadRegistryError | ||
| 107 | | DesktopSavedEnvironmentSecretDecodeError | 157 | | DesktopSavedEnvironmentSecretDecodeError |
| 108 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | 158 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError |
| 109 | | ElectronSafeStorage.ElectronSafeStorageDecryptError; | 159 | | ElectronSafeStorage.ElectronSafeStorageDecryptError; |
| 110 | 160 | ||
| 111 | export type DesktopSavedEnvironmentsSetSecretError = | 161 | export type DesktopSavedEnvironmentsSetSecretError = |
| 112 | | DesktopSavedEnvironmentsWriteError | 162 | | DesktopSavedEnvironmentsMutationError |
| 113 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | 163 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError |
| 114 | | ElectronSafeStorage.ElectronSafeStorageEncryptError; | 164 | | ElectronSafeStorage.ElectronSafeStorageEncryptError; |
| 115 | 165 | ||
| 116 | export 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 | |||
| 139 | export class DesktopSavedEnvironments extends Context.Service< | 166 | export class DesktopSavedEnvironments extends Context.Service< |
| 140 | DesktopSavedEnvironments, | 167 | 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 | } | ||
| 142 | >()("@t3tools/desktop/settings/DesktopSavedEnvironments") {} | 190 | >()("@t3tools/desktop/settings/DesktopSavedEnvironments") {} |
| 143 | 191 | ||
| 144 | function toPersistedSavedEnvironmentRecord( | 192 | function toPersistedSavedEnvironmentRecord( |
| ... | @@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument( | ... | @@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument( |
| 193 | function readRegistryDocument( | 241 | function 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 === null | 258 | 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 | ); |
| 230 | 301 | ||
| ... | @@ -250,147 +321,160 @@ function preserveExistingSecrets( | ... | @@ -250,147 +321,160 @@ function preserveExistingSecrets( |
| 250 | } | 321 | } |
| 251 | 322 | ||
| 252 | function decodeSecretBytes( | 323 | function 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 | } |
| 259 | 341 | ||
| 260 | export const layer = Layer.effect( | 342 | export 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) => |
| 268 | 350 | 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 | } | ||
| 329 | 365 | ||
| 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 | } |
| 380 | 390 | ||
| 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 | |||
| 477 | export const layer = Layer.effect(DesktopSavedEnvironments, make); | ||
| 394 | 478 | ||
| 395 | export const layerTest = (input?: { | 479 | export const layerTest = (input?: { |
| 396 | readonly records?: readonly PersistedSavedEnvironmentRecord[]; | 480 | readonly records?: readonly PersistedSavedEnvironmentRecord[]; |