import { BearerConnectionCredential, BearerConnectionProfile, BearerConnectionTarget, RelayConnectionTarget, SshConnectionProfile, SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { ConnectionCatalogDocument as RuntimeConnectionCatalogDocument, type ConnectionCatalogDocument as RuntimeConnectionCatalogDocumentType, } from "@t3tools/client-runtime/platform"; import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const EncryptedConnectionCatalogDocument = Schema.Struct({ version: Schema.Literal(1), encryptedCatalog: Schema.String, }); type EncryptedConnectionCatalogDocument = typeof EncryptedConnectionCatalogDocument.Type; const EncryptedConnectionCatalogDocumentJson = fromLenientJson(EncryptedConnectionCatalogDocument); const decodeEncryptedConnectionCatalogDocumentJson = Schema.decodeEffect( EncryptedConnectionCatalogDocumentJson, ); const encodeEncryptedConnectionCatalogDocumentJson = Schema.encodeEffect( EncryptedConnectionCatalogDocumentJson, ); const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString( RuntimeConnectionCatalogDocument, ); const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( RuntimeConnectionCatalogDocumentJson, ); const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", "create-directory", "write-temporary-file", "replace-catalog-file", ]); type DesktopConnectionCatalogStoreWriteOperation = typeof DesktopConnectionCatalogStoreWriteOperation.Type; const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ "read-legacy-registry", "read-legacy-secret", "encode-catalog", "persist-catalog", ]); type DesktopConnectionCatalogStoreMigrationOperation = typeof DesktopConnectionCatalogStoreMigrationOperation.Type; export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreWriteError", { operation: DesktopConnectionCatalogStoreWriteOperation, path: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`; } } const writeError = ( operation: DesktopConnectionCatalogStoreWriteOperation, path: string, cause: unknown, ): DesktopConnectionCatalogStoreWriteError => new DesktopConnectionCatalogStoreWriteError({ operation, path, cause, }); export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { resource: Schema.Literal("encryptedCatalog"), catalogPath: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to decode ${this.resource} for the desktop connection catalog at ${this.catalogPath}.`; } } export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreReadError", { catalogPath: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to read the desktop connection catalog at ${this.catalogPath}.`; } } export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDocumentDecodeError", { catalogPath: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`; } } export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreMigrationError", { operation: DesktopConnectionCatalogStoreMigrationOperation, catalogPath: Schema.String, environmentId: Schema.optionalKey(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { const environment = this.environmentId === undefined ? "" : ` for environment ${this.environmentId}`; return `Legacy desktop saved-environment migration failed during ${this.operation}${environment} into ${this.catalogPath}.`; } } const migrationError = ( operation: DesktopConnectionCatalogStoreMigrationOperation, catalogPath: string, cause: unknown, environmentId?: string, ): DesktopConnectionCatalogStoreMigrationError => new DesktopConnectionCatalogStoreMigrationError({ operation, catalogPath, ...(environmentId === undefined ? {} : { environmentId }), cause, }); export class DesktopConnectionCatalogStore extends Context.Service< DesktopConnectionCatalogStore, { readonly get: Effect.Effect< Option.Option, | DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError | ElectronSafeStorage.ElectronSafeStorageError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError >; readonly clear: Effect.Effect; } >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} function decodeSecretBytes( catalogPath: string, encoded: string, ): Effect.Effect { return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDecodeError({ resource: "encryptedCatalog", catalogPath, cause, }), ), ); } const readDocument = ( fileSystem: FileSystem.FileSystem, catalogPath: string, ): Effect.Effect< Option.Option, DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError > => fileSystem.readFileString(catalogPath).pipe( Effect.catch((error) => error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail( new DesktopConnectionCatalogStoreReadError({ catalogPath, cause: error, }), ), ), Effect.flatMap((raw) => raw === null ? Effect.succeed(Option.none()) : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( Effect.map(Option.some), Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDocumentDecodeError({ catalogPath, cause, }), ), ), ), ); const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly catalogPath: string; readonly document: EncryptedConnectionCatalogDocument; readonly suffix: string; }): Effect.fn.Return { const directory = input.path.dirname(input.catalogPath); const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe( Effect.mapError((cause) => writeError("encode-document", input.catalogPath, cause)), ); yield* input.fileSystem .makeDirectory(directory, { recursive: true }) .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); yield* Effect.gen(function* () { yield* input.fileSystem .writeFileString(tempPath, `${encoded}\n`) .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); yield* input.fileSystem .rename(tempPath, input.catalogPath) .pipe( Effect.mapError((cause) => writeError("replace-catalog-file", input.catalogPath, cause)), ); }).pipe( Effect.ensuring( input.fileSystem.remove(tempPath, { force: true }).pipe( Effect.catch((error) => Effect.logWarning("Could not remove a temporary connection catalog file.", { tempPath, error, }), ), ), ), ); }); function connectionId(prefix: "bearer" | "ssh", environmentId: string): string { return `${prefix}:${environmentId}`; } const migrateSavedEnvironmentRecords = Effect.fn( "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords", )(function* ( records: readonly PersistedSavedEnvironmentRecord[], savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"], catalogPath: string, ): Effect.fn.Return< RuntimeConnectionCatalogDocumentType, DesktopConnectionCatalogStoreMigrationError > { const targets: Array = []; const profiles: Array = []; const credentials: Array = []; for (const record of records) { if (record.relayManaged !== undefined) { targets.push( new RelayConnectionTarget({ environmentId: record.environmentId, label: record.label, }), ); continue; } if (record.desktopSsh !== undefined) { const id = connectionId("ssh", record.environmentId); targets.push( new SshConnectionTarget({ environmentId: record.environmentId, label: record.label, connectionId: id, }), ); profiles.push( new SshConnectionProfile({ connectionId: id, environmentId: record.environmentId, label: record.label, target: record.desktopSsh, }), ); continue; } const id = connectionId("bearer", record.environmentId); targets.push( new BearerConnectionTarget({ environmentId: record.environmentId, label: record.label, connectionId: id, }), ); profiles.push( new BearerConnectionProfile({ connectionId: id, environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, wsBaseUrl: record.wsBaseUrl, }), ); const token = yield* savedEnvironments .getSecret(record.environmentId) .pipe( Effect.mapError((cause) => migrationError("read-legacy-secret", catalogPath, cause, record.environmentId), ), ); if (Option.isSome(token)) { credentials.push({ connectionId: id, credential: new BearerConnectionCredential({ token: token.value }), }); } } return { schemaVersion: 1, targets, profiles, credentials, remoteDpopTokens: [], }; }); export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( catalog: string, ) { const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); const suffix = (yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)), )).replace(/-/g, ""); yield* writeDocument({ fileSystem, path, catalogPath, document: { version: 1, encryptedCatalog }, suffix, }); }); const migrateLegacyCatalog = Effect.gen(function* () { if (!(yield* safeStorage.isEncryptionAvailable)) { return Option.none(); } const records = yield* savedEnvironments.getRegistry.pipe( Effect.mapError((cause) => migrationError("read-legacy-registry", catalogPath, cause)), ); if (records.length === 0) { return Option.none(); } const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath); const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe( Effect.mapError((cause) => migrationError("encode-catalog", catalogPath, cause)), ); yield* writeCatalog(encoded).pipe( Effect.mapError((cause) => migrationError("persist-catalog", catalogPath, cause)), ); return Option.some(encoded); }); return DesktopConnectionCatalogStore.of({ get: Effect.gen(function* () { const document = yield* readDocument(fileSystem, catalogPath); if (Option.isNone(document)) { return yield* migrateLegacyCatalog; } if (!(yield* safeStorage.isEncryptionAvailable)) { return Option.none(); } const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( Effect.flatMap(safeStorage.decryptString), ); return Option.some(decrypted); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { if (!(yield* safeStorage.isEncryptionAvailable)) { return false; } yield* writeCatalog(catalog); return true; }), clear: fileSystem.remove(catalogPath, { force: true }).pipe( Effect.catch((error) => Effect.logWarning("Could not clear the desktop connection catalog.", { catalogPath, error, }), ), Effect.withSpan("desktop.connectionCatalogStore.clear"), ), }); }); export const layer = Layer.effect(DesktopConnectionCatalogStore, make);