Open History to see the last commit
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
133export class DesktopSavedEnvironments extends Context.Service<
134 DesktopSavedEnvironments,
135 {
136 readonly getRegistry: Effect.Effect<
137 readonly PersistedSavedEnvironmentRecord[],
138 DesktopSavedEnvironmentsReadRegistryError
139 >;
140
141 readonly getSecret: (
142 environmentId: string,
143 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
144 }
145>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
146
147function toPersistedSavedEnvironmentRecord(
148 record: PersistedSavedEnvironmentStorageRecord,
149): PersistedSavedEnvironmentRecord {
150 const nextRecord = {
151 environmentId: record.environmentId,
152 label: record.label,
153 httpBaseUrl: record.httpBaseUrl,
154 wsBaseUrl: record.wsBaseUrl,
155 createdAt: record.createdAt,
156 lastConnectedAt: record.lastConnectedAt,
157 };
158 return {
159 ...nextRecord,
160 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
161 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
162 };
163}
164
165function normalizeSavedEnvironmentRegistryDocument(
166 document: SavedEnvironmentRegistryStorageDocument,
167): SavedEnvironmentRegistryDocument {
168 return {
169 version: document.version ?? 1,
170 records: document.records ?? [],
171 };
172}
173
174function readRegistryDocument(
175 fileSystem: FileSystem.FileSystem,
176 registryPath: string,
177): Effect.Effect<SavedEnvironmentRegistryDocument, DesktopSavedEnvironmentsReadRegistryError> {
178 return fileSystem.readFileString(registryPath).pipe(
179 Effect.catch((error) =>
180 error.reason._tag === "NotFound"
181 ? Effect.succeed<string | null>(null)
182 : Effect.fail(
183 new DesktopSavedEnvironmentsReadError({
184 registryPath,
185 cause: error,
186 }),
187 ),
188 ),
189 Effect.flatMap((raw) =>
190 raw === null
191 ? Effect.succeed({ version: 1, records: [] })
192 : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(
193 Effect.map(normalizeSavedEnvironmentRegistryDocument),
194 Effect.mapError(
195 (cause) =>
196 new DesktopSavedEnvironmentsDocumentDecodeError({
197 registryPath,
198 cause,
199 }),
200 ),
201 ),
202 ),
203 );
204}
205
206function decodeSecretBytes(
207 environmentId: string,
208 registryPath: string,
209 encoded: string,
210): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {
211 return Effect.fromResult(Base64.decode(encoded)).pipe(
212 Effect.mapError(
213 (cause) =>
214 new DesktopSavedEnvironmentSecretDecodeError({
215 environmentId,
216 registryPath,
217 field: "encryptedBearerToken",
218 cause,
219 }),
220 ),
221 );
222}
223
224/** @public */
225export const make = Effect.gen(function* () {
226 const environment = yield* DesktopEnvironment.DesktopEnvironment;
227 const fileSystem = yield* FileSystem.FileSystem;
228
229 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
230
231 return DesktopSavedEnvironments.of({
232 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
233 Effect.map((document) =>
234 document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
235 ),
236 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
237 ),
238
239 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
240 yield* Effect.annotateCurrentSpan({ environmentId });
241 const document = yield* readRegistryDocument(
242 fileSystem,
243 environment.savedEnvironmentRegistryPath,
244 );
245 const encoded = Option.fromNullishOr(
246 document.records.find((record) => record.environmentId === environmentId)
247 ?.encryptedBearerToken,
248 );
249 if (Option.isNone(encoded)) {
250 return Option.none<string>();
251 }
252 const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe(
253 Effect.mapError(
254 (cause) =>
255 new DesktopSavedEnvironmentSecretProtectionError({
256 operation: "check-encryption-availability",
257 environmentId,
258 registryPath: environment.savedEnvironmentRegistryPath,
259 cause,
260 }),
261 ),
262 );
263 if (!encryptionAvailable) {
264 return Option.none<string>();
265 }
266
267 const secretBytes = yield* decodeSecretBytes(
268 environmentId,
269 environment.savedEnvironmentRegistryPath,
270 encoded.value,
271 );
272 return Option.some(
273 yield* safeStorage.decryptString(secretBytes).pipe(
274 Effect.mapError(
275 (cause) =>
276 new DesktopSavedEnvironmentSecretProtectionError({
277 operation: "decrypt-secret",
278 environmentId,
279 registryPath: environment.savedEnvironmentRegistryPath,
280 cause,
281 }),
282 ),
283 ),
284 );
285 }),
286 });
287});
288
289export const layer = Layer.effect(DesktopSavedEnvironments, make);