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