1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Base64 from "effect/encoding/Base64";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
13
14type PersistedSavedEnvironmentDesktopSsh = NonNullable<
15 PersistedSavedEnvironmentRecord["desktopSsh"]
16>;
17
18interface PersistedSavedEnvironmentStorageRecord extends Omit<
19 PersistedSavedEnvironmentRecord,
20 "desktopSsh"
21> {
22 readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh;
23 readonly encryptedBearerToken?: string;
24}
25
26interface SavedEnvironmentRegistryDocument {
27 readonly version: number;
28 readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
29}
30
31interface SavedEnvironmentRegistryStorageDocument {
32 readonly version?: number;
33 readonly records?: readonly PersistedSavedEnvironmentStorageRecord[];
34}
35
36const DesktopSshTargetSchema = Schema.Struct({
37 alias: Schema.String,
38 hostname: Schema.String,
39 username: Schema.NullOr(Schema.String),
40 port: Schema.NullOr(Schema.Number),
41});
42
43const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({
44 environmentId: EnvironmentId,
45 label: Schema.String,
46 httpBaseUrl: Schema.String,
47 wsBaseUrl: Schema.String,
48 createdAt: Schema.String,
49 lastConnectedAt: Schema.NullOr(Schema.String),
50 desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),
51 relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
52 encryptedBearerToken: Schema.optionalKey(Schema.String),
53});
54
55const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({
56 version: Schema.optionalKey(Schema.Number),
57 records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)),
58});
59
60const SavedEnvironmentRegistryDocumentJson = fromLenientJson(
61 SavedEnvironmentRegistryDocumentSchema,
62);
63const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(
64 SavedEnvironmentRegistryDocumentJson,
65);
66
67const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([
68 "check-encryption-availability",
69 "decrypt-secret",
70]);
71
72export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError<DesktopSavedEnvironmentsReadError>()(
73 "DesktopSavedEnvironmentsReadError",
74 {
75 registryPath: Schema.String,
76 cause: Schema.Defect(),
77 },
78) {
79 override get message(): string {
80 return `Failed to read desktop saved environments at ${this.registryPath}.`;
81 }
82}
83
84export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedError<DesktopSavedEnvironmentsDocumentDecodeError>()(
85 "DesktopSavedEnvironmentsDocumentDecodeError",
86 {
87 registryPath: Schema.String,
88 cause: Schema.Defect(),
89 },
90) {
91 override get message(): string {
92 return `Failed to decode desktop saved environments at ${this.registryPath}.`;
93 }
94}
95
96export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedError<DesktopSavedEnvironmentSecretDecodeError>()(
97 "DesktopSavedEnvironmentSecretDecodeError",
98 {
99 environmentId: Schema.String,
100 registryPath: Schema.String,
101 field: Schema.Literal("encryptedBearerToken"),
102 cause: Schema.Defect(),
103 },
104) {
105 override get message(): string {
106 return `Failed to decode ${this.field} for environment ${this.environmentId} at ${this.registryPath}.`;
107 }
108}
109
110export class DesktopSavedEnvironmentSecretProtectionError extends Schema.TaggedError<DesktopSavedEnvironmentSecretProtectionError>()(
111 "DesktopSavedEnvironmentSecretProtectionError",
112 {
113 operation: DesktopSavedEnvironmentSecretProtectionOperation,
114 environmentId: Schema.String,
115 registryPath: Schema.String,
116 cause: Schema.Defect(),
117 },
118) {
119 override get message(): string {
120 return `Desktop saved-environment secret protection failed during ${this.operation} for environment ${this.environmentId} at ${this.registryPath}.`;
121 }
122}
123
124export type DesktopSavedEnvironmentsReadRegistryError =
125 | DesktopSavedEnvironmentsReadError
126 | DesktopSavedEnvironmentsDocumentDecodeError;
127
128export type DesktopSavedEnvironmentsGetSecretError =
129 | DesktopSavedEnvironmentsReadRegistryError
130 | DesktopSavedEnvironmentSecretDecodeError
131 | DesktopSavedEnvironmentSecretProtectionError;
132
133/** Reads the previous registry for connection-catalog migration. */
134export class DesktopSavedEnvironments extends Context.Service<
135 DesktopSavedEnvironments,
136 {
137 readonly getRegistry: Effect.Effect<
138 readonly PersistedSavedEnvironmentRecord[],
139 DesktopSavedEnvironmentsReadRegistryError
140 >;
141
142 readonly getSecret: (
143 environmentId: string,
144 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
145 }
146>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
147
148function toPersistedSavedEnvironmentRecord(
149 record: PersistedSavedEnvironmentStorageRecord,
150): PersistedSavedEnvironmentRecord {
151 const nextRecord = {
152 environmentId: record.environmentId,
153 label: record.label,
154 httpBaseUrl: record.httpBaseUrl,
155 wsBaseUrl: record.wsBaseUrl,
156 createdAt: record.createdAt,
157 lastConnectedAt: record.lastConnectedAt,
158 };
159 return {
160 ...nextRecord,
161 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
162 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
163 };
164}
165
166function normalizeSavedEnvironmentRegistryDocument(
167 document: SavedEnvironmentRegistryStorageDocument,
168): SavedEnvironmentRegistryDocument {
169 return {
170 version: document.version ?? 1,
171 records: document.records ?? [],
172 };
173}
174
175function readRegistryDocument(
176 fileSystem: FileSystem.FileSystem,
177 registryPath: string,
178): Effect.Effect<SavedEnvironmentRegistryDocument, DesktopSavedEnvironmentsReadRegistryError> {
179 return fileSystem.readFileString(registryPath).pipe(
180 Effect.catch((error) =>
181 error.reason._tag === "NotFound"
182 ? Effect.succeed<string | null>(null)
183 : Effect.fail(
184 new DesktopSavedEnvironmentsReadError({
185 registryPath,
186 cause: error,
187 }),
188 ),
189 ),
190 Effect.flatMap((raw) =>
191 raw === null
192 ? Effect.succeed({ version: 1, records: [] })
193 : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(
194 Effect.map(normalizeSavedEnvironmentRegistryDocument),
195 Effect.mapError(
196 (cause) =>
197 new DesktopSavedEnvironmentsDocumentDecodeError({
198 registryPath,
199 cause,
200 }),
201 ),
202 ),
203 ),
204 );
205}
206
207function decodeSecretBytes(
208 environmentId: string,
209 registryPath: string,
210 encoded: string,
211): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {
212 return Effect.fromResult(Base64.decode(encoded)).pipe(
213 Effect.mapError(
214 (cause) =>
215 new DesktopSavedEnvironmentSecretDecodeError({
216 environmentId,
217 registryPath,
218 field: "encryptedBearerToken",
219 cause,
220 }),
221 ),
222 );
223}
224
225/** @public Service construction is part of the canonical Effect module API. */
226export const make = Effect.gen(function* () {
227 const environment = yield* DesktopEnvironment.DesktopEnvironment;
228 const fileSystem = yield* FileSystem.FileSystem;
229
230 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
231
232 return DesktopSavedEnvironments.of({
233 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
234 Effect.map((document) =>
235 document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
236 ),
237 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
238 ),
239
240 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
241 yield* Effect.annotateCurrentSpan({ environmentId });
242 const document = yield* readRegistryDocument(
243 fileSystem,
244 environment.savedEnvironmentRegistryPath,
245 );
246 const encoded = Option.fromNullishOr(
247 document.records.find((record) => record.environmentId === environmentId)
248 ?.encryptedBearerToken,
249 );
250 if (Option.isNone(encoded)) {
251 return Option.none<string>();
252 }
253 const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe(
254 Effect.mapError(
255 (cause) =>
256 new DesktopSavedEnvironmentSecretProtectionError({
257 operation: "check-encryption-availability",
258 environmentId,
259 registryPath: environment.savedEnvironmentRegistryPath,
260 cause,
261 }),
262 ),
263 );
264 if (!encryptionAvailable) {
265 return Option.none<string>();
266 }
267
268 const secretBytes = yield* decodeSecretBytes(
269 environmentId,
270 environment.savedEnvironmentRegistryPath,
271 encoded.value,
272 );
273 return Option.some(
274 yield* safeStorage.decryptString(secretBytes).pipe(
275 Effect.mapError(
276 (cause) =>
277 new DesktopSavedEnvironmentSecretProtectionError({
278 operation: "decrypt-secret",
279 environmentId,
280 registryPath: environment.savedEnvironmentRegistryPath,
281 cause,
282 }),
283 ),
284 ),
285 );
286 }),
287 });
288});
289
290export const layer = Layer.effect(DesktopSavedEnvironments, make);