1import {
2 BearerConnectionCredential,
3 BearerConnectionProfile,
4 BearerConnectionTarget,
5 RelayConnectionTarget,
6 SshConnectionProfile,
7 SshConnectionTarget,
8} from "@t3tools/client-runtime/connection";
9import {
10 ConnectionCatalogDocument as RuntimeConnectionCatalogDocument,
11 type ConnectionCatalogDocument as RuntimeConnectionCatalogDocumentType,
12} from "@t3tools/client-runtime/platform";
13import type { PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
14import { fromLenientJson } from "@t3tools/shared/schemaJson";
15import * as Context from "effect/Context";
16import * as Crypto from "effect/Crypto";
17import * as Effect from "effect/Effect";
18import * as Base64 from "effect/encoding/Base64";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24
25import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
26import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts";
27import * as DesktopEnvironment from "./DesktopEnvironment.ts";
28
29const EncryptedConnectionCatalogDocument = Schema.Struct({
30 version: Schema.Literal(1),
31 encryptedCatalog: Schema.String,
32});
33type EncryptedConnectionCatalogDocument = typeof EncryptedConnectionCatalogDocument.Type;
34
35const EncryptedConnectionCatalogDocumentJson = fromLenientJson(EncryptedConnectionCatalogDocument);
36const decodeEncryptedConnectionCatalogDocumentJson = Schema.decodeEffect(
37 EncryptedConnectionCatalogDocumentJson,
38);
39const encodeEncryptedConnectionCatalogDocumentJson = Schema.encodeEffect(
40 EncryptedConnectionCatalogDocumentJson,
41);
42const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString(
43 RuntimeConnectionCatalogDocument,
44);
45const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect(
46 RuntimeConnectionCatalogDocumentJson,
47);
48
49const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([
50 "create-temporary-file-name",
51 "encode-document",
52 "create-directory",
53 "write-temporary-file",
54 "replace-catalog-file",
55]);
56
57const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([
58 "read-legacy-registry",
59 "read-legacy-secret",
60 "encode-catalog",
61 "persist-catalog",
62]);
63
64const DesktopConnectionCatalogStoreProtectionOperation = Schema.Literals([
65 "check-encryption-availability",
66 "encrypt-catalog",
67 "decrypt-catalog",
68]);
69
70export class DesktopConnectionCatalogStoreWriteError extends Schema.TaggedError<DesktopConnectionCatalogStoreWriteError>()(
71 "DesktopConnectionCatalogStoreWriteError",
72 {
73 operation: DesktopConnectionCatalogStoreWriteOperation,
74 path: Schema.String,
75 cause: Schema.Defect(),
76 },
77) {
78 override get message(): string {
79 return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`;
80 }
81}
82
83export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedError<DesktopConnectionCatalogStoreDecodeError>()(
84 "DesktopConnectionCatalogStoreDecodeError",
85 {
86 resource: Schema.Literal("encryptedCatalog"),
87 catalogPath: Schema.String,
88 cause: Schema.Defect(),
89 },
90) {
91 override get message(): string {
92 return `Failed to decode ${this.resource} for the desktop connection catalog at ${this.catalogPath}.`;
93 }
94}
95
96export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedError<DesktopConnectionCatalogStoreReadError>()(
97 "DesktopConnectionCatalogStoreReadError",
98 {
99 catalogPath: Schema.String,
100 cause: Schema.Defect(),
101 },
102) {
103 override get message(): string {
104 return `Failed to read the desktop connection catalog at ${this.catalogPath}.`;
105 }
106}
107
108export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedError<DesktopConnectionCatalogStoreDocumentDecodeError>()(
109 "DesktopConnectionCatalogStoreDocumentDecodeError",
110 {
111 catalogPath: Schema.String,
112 cause: Schema.Defect(),
113 },
114) {
115 override get message(): string {
116 return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`;
117 }
118}
119
120export class DesktopConnectionCatalogStoreMigrationError extends Schema.TaggedError<DesktopConnectionCatalogStoreMigrationError>()(
121 "DesktopConnectionCatalogStoreMigrationError",
122 {
123 operation: DesktopConnectionCatalogStoreMigrationOperation,
124 catalogPath: Schema.String,
125 environmentId: Schema.optionalKey(Schema.String),
126 cause: Schema.Defect(),
127 },
128) {
129 override get message(): string {
130 const environment =
131 this.environmentId === undefined ? "" : ` for environment ${this.environmentId}`;
132 return `Legacy desktop saved-environment migration failed during ${this.operation}${environment} into ${this.catalogPath}.`;
133 }
134}
135
136export class DesktopConnectionCatalogStoreProtectionError extends Schema.TaggedError<DesktopConnectionCatalogStoreProtectionError>()(
137 "DesktopConnectionCatalogStoreProtectionError",
138 {
139 operation: DesktopConnectionCatalogStoreProtectionOperation,
140 catalogPath: Schema.String,
141 cause: Schema.Defect(),
142 },
143) {
144 override get message(): string {
145 return `Desktop connection catalog protection failed during ${this.operation} at ${this.catalogPath}.`;
146 }
147}
148
149export class DesktopConnectionCatalogStore extends Context.Service<
150 DesktopConnectionCatalogStore,
151 {
152 readonly get: Effect.Effect<
153 Option.Option<string>,
154 | DesktopConnectionCatalogStoreReadError
155 | DesktopConnectionCatalogStoreDocumentDecodeError
156 | DesktopConnectionCatalogStoreDecodeError
157 | DesktopConnectionCatalogStoreMigrationError
158 | DesktopConnectionCatalogStoreProtectionError
159 >;
160 readonly set: (
161 catalog: string,
162 ) => Effect.Effect<
163 boolean,
164 DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError
165 >;
166 readonly clear: Effect.Effect<void>;
167 }
168>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {}
169
170function decodeSecretBytes(
171 catalogPath: string,
172 encoded: string,
173): Effect.Effect<Uint8Array, DesktopConnectionCatalogStoreDecodeError> {
174 return Effect.fromResult(Base64.decode(encoded)).pipe(
175 Effect.mapError(
176 (cause) =>
177 new DesktopConnectionCatalogStoreDecodeError({
178 resource: "encryptedCatalog",
179 catalogPath,
180 cause,
181 }),
182 ),
183 );
184}
185
186const readDocument = (
187 fileSystem: FileSystem.FileSystem,
188 catalogPath: string,
189): Effect.Effect<
190 Option.Option<EncryptedConnectionCatalogDocument>,
191 DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError
192> =>
193 fileSystem.readFileString(catalogPath).pipe(
194 Effect.catch((error) =>
195 error.reason._tag === "NotFound"
196 ? Effect.succeed<string | null>(null)
197 : Effect.fail(
198 new DesktopConnectionCatalogStoreReadError({
199 catalogPath,
200 cause: error,
201 }),
202 ),
203 ),
204 Effect.flatMap((raw) =>
205 raw === null
206 ? Effect.succeed(Option.none<EncryptedConnectionCatalogDocument>())
207 : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(
208 Effect.asSome,
209 Effect.mapError(
210 (cause) =>
211 new DesktopConnectionCatalogStoreDocumentDecodeError({
212 catalogPath,
213 cause,
214 }),
215 ),
216 ),
217 ),
218 );
219
220const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: {
221 readonly fileSystem: FileSystem.FileSystem;
222 readonly path: Path.Path;
223 readonly catalogPath: string;
224 readonly document: EncryptedConnectionCatalogDocument;
225 readonly suffix: string;
226}): Effect.fn.Return<void, DesktopConnectionCatalogStoreWriteError> {
227 const directory = input.path.dirname(input.catalogPath);
228 const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`;
229 const encoded = yield* encodeEncryptedConnectionCatalogDocumentJson(input.document).pipe(
230 Effect.mapError(
231 (cause) =>
232 new DesktopConnectionCatalogStoreWriteError({
233 operation: "encode-document",
234 path: input.catalogPath,
235 cause,
236 }),
237 ),
238 );
239 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
240 Effect.mapError(
241 (cause) =>
242 new DesktopConnectionCatalogStoreWriteError({
243 operation: "create-directory",
244 path: directory,
245 cause,
246 }),
247 ),
248 );
249 yield* Effect.gen(function* () {
250 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
251 Effect.mapError(
252 (cause) =>
253 new DesktopConnectionCatalogStoreWriteError({
254 operation: "write-temporary-file",
255 path: tempPath,
256 cause,
257 }),
258 ),
259 );
260 yield* input.fileSystem.rename(tempPath, input.catalogPath).pipe(
261 Effect.mapError(
262 (cause) =>
263 new DesktopConnectionCatalogStoreWriteError({
264 operation: "replace-catalog-file",
265 path: input.catalogPath,
266 cause,
267 }),
268 ),
269 );
270 }).pipe(
271 Effect.ensuring(
272 input.fileSystem.remove(tempPath, { force: true }).pipe(
273 Effect.catch((error) =>
274 Effect.logWarning("Could not remove a temporary connection catalog file.", {
275 tempPath,
276 error,
277 }),
278 ),
279 ),
280 ),
281 );
282});
283
284function connectionId(prefix: "bearer" | "ssh", environmentId: string): string {
285 return `${prefix}:${environmentId}`;
286}
287
288const migrateSavedEnvironmentRecords = Effect.fn(
289 "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords",
290)(function* (
291 records: readonly PersistedSavedEnvironmentRecord[],
292 savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"],
293 catalogPath: string,
294): Effect.fn.Return<
295 RuntimeConnectionCatalogDocumentType,
296 DesktopConnectionCatalogStoreMigrationError
297> {
298 const targets: Array<RuntimeConnectionCatalogDocumentType["targets"][number]> = [];
299 const profiles: Array<RuntimeConnectionCatalogDocumentType["profiles"][number]> = [];
300 const credentials: Array<RuntimeConnectionCatalogDocumentType["credentials"][number]> = [];
301
302 for (const record of records) {
303 if (record.relayManaged !== undefined) {
304 targets.push(
305 new RelayConnectionTarget({
306 environmentId: record.environmentId,
307 label: record.label,
308 }),
309 );
310 continue;
311 }
312
313 if (record.desktopSsh !== undefined) {
314 const id = connectionId("ssh", record.environmentId);
315 targets.push(
316 new SshConnectionTarget({
317 environmentId: record.environmentId,
318 label: record.label,
319 connectionId: id,
320 }),
321 );
322 profiles.push(
323 new SshConnectionProfile({
324 connectionId: id,
325 environmentId: record.environmentId,
326 label: record.label,
327 target: record.desktopSsh,
328 }),
329 );
330 continue;
331 }
332
333 const id = connectionId("bearer", record.environmentId);
334 targets.push(
335 new BearerConnectionTarget({
336 environmentId: record.environmentId,
337 label: record.label,
338 connectionId: id,
339 }),
340 );
341 profiles.push(
342 new BearerConnectionProfile({
343 connectionId: id,
344 environmentId: record.environmentId,
345 label: record.label,
346 httpBaseUrl: record.httpBaseUrl,
347 wsBaseUrl: record.wsBaseUrl,
348 }),
349 );
350 const token = yield* savedEnvironments.getSecret(record.environmentId).pipe(
351 Effect.mapError(
352 (cause) =>
353 new DesktopConnectionCatalogStoreMigrationError({
354 operation: "read-legacy-secret",
355 catalogPath,
356 environmentId: record.environmentId,
357 cause,
358 }),
359 ),
360 );
361 if (Option.isSome(token)) {
362 credentials.push({
363 connectionId: id,
364 credential: new BearerConnectionCredential({ token: token.value }),
365 });
366 }
367 }
368
369 return {
370 schemaVersion: 1,
371 targets,
372 profiles,
373 credentials,
374 remoteDpopTokens: [],
375 disabledEnvironmentIds: [],
376 };
377});
378
379/** @public Service construction is part of the canonical Effect module API. */
380export const make = Effect.gen(function* () {
381 const environment = yield* DesktopEnvironment.DesktopEnvironment;
382 const fileSystem = yield* FileSystem.FileSystem;
383 const path = yield* Path.Path;
384 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
385 const crypto = yield* Crypto.Crypto;
386 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
387 const catalogPath = path.join(environment.stateDir, "connection-catalog.json");
388 const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe(
389 Effect.mapError(
390 (cause) =>
391 new DesktopConnectionCatalogStoreProtectionError({
392 operation: "check-encryption-availability",
393 catalogPath,
394 cause,
395 }),
396 ),
397 );
398
399 const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* (
400 catalog: string,
401 ) {
402 const encryptedCatalog = Base64.encode(
403 yield* safeStorage.encryptString(catalog).pipe(
404 Effect.mapError(
405 (cause) =>
406 new DesktopConnectionCatalogStoreProtectionError({
407 operation: "encrypt-catalog",
408 catalogPath,
409 cause,
410 }),
411 ),
412 ),
413 );
414 const suffix = (yield* crypto.randomUUIDv4().pipe(
415 Effect.mapError(
416 (cause) =>
417 new DesktopConnectionCatalogStoreWriteError({
418 operation: "create-temporary-file-name",
419 path: catalogPath,
420 cause,
421 }),
422 ),
423 )).replace(/-/g, "");
424 yield* writeDocument({
425 fileSystem,
426 path,
427 catalogPath,
428 document: { version: 1, encryptedCatalog },
429 suffix,
430 });
431 });
432
433 const migrateLegacyCatalog = Effect.gen(function* () {
434 if (!(yield* encryptionAvailable)) {
435 return Option.none<string>();
436 }
437 const records = yield* savedEnvironments.getRegistry.pipe(
438 Effect.mapError(
439 (cause) =>
440 new DesktopConnectionCatalogStoreMigrationError({
441 operation: "read-legacy-registry",
442 catalogPath,
443 cause,
444 }),
445 ),
446 );
447 if (records.length === 0) {
448 return Option.none<string>();
449 }
450 const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments, catalogPath);
451 const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog).pipe(
452 Effect.mapError(
453 (cause) =>
454 new DesktopConnectionCatalogStoreMigrationError({
455 operation: "encode-catalog",
456 catalogPath,
457 cause,
458 }),
459 ),
460 );
461 yield* writeCatalog(encoded).pipe(
462 Effect.mapError(
463 (cause) =>
464 new DesktopConnectionCatalogStoreMigrationError({
465 operation: "persist-catalog",
466 catalogPath,
467 cause,
468 }),
469 ),
470 );
471 return Option.some(encoded);
472 });
473
474 return DesktopConnectionCatalogStore.of({
475 get: Effect.gen(function* () {
476 const document = yield* readDocument(fileSystem, catalogPath);
477 if (Option.isNone(document)) {
478 return yield* migrateLegacyCatalog;
479 }
480 if (!(yield* encryptionAvailable)) {
481 return Option.none<string>();
482 }
483 const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe(
484 Effect.flatMap((encryptedCatalog) =>
485 safeStorage.decryptString(encryptedCatalog).pipe(
486 Effect.mapError(
487 (cause) =>
488 new DesktopConnectionCatalogStoreProtectionError({
489 operation: "decrypt-catalog",
490 catalogPath,
491 cause,
492 }),
493 ),
494 ),
495 ),
496 );
497 return Option.some(decrypted);
498 }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")),
499 set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) {
500 if (!(yield* encryptionAvailable)) {
501 return false;
502 }
503 yield* writeCatalog(catalog);
504 return true;
505 }),
506 clear: fileSystem.remove(catalogPath, { force: true }).pipe(
507 Effect.catch((error) =>
508 Effect.logWarning("Could not clear the desktop connection catalog.", {
509 catalogPath,
510 error,
511 }),
512 ),
513 Effect.withSpan("desktop.connectionCatalogStore.clear"),
514 ),
515 });
516});
517
518export const layer = Layer.effect(DesktopConnectionCatalogStore, make);