| 1 | import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; |
| 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 3 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Crypto from "effect/Crypto"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import * as Ref from "effect/Ref"; |
| 13 | |
| 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 15 | |
| 16 | const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); |
| 17 | const decodeClientSettingsDocument = Schema.decodeEffect( |
| 18 | fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), |
| 19 | ); |
| 20 | const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); |
| 21 | const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { |
| 22 | const document = yield* decodeClientSettingsDocument(raw); |
| 23 | // Select the shape before validation so invalid legacy settings cannot become defaults. |
| 24 | return yield* decodeClientSettingsValue( |
| 25 | Object.hasOwn(document, "settings") ? document.settings : document, |
| 26 | ); |
| 27 | }); |
| 28 | const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); |
| 29 | |
| 30 | export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopClientSettingsReadError>()( |
| 31 | "DesktopClientSettingsReadError", |
| 32 | { |
| 33 | operation: Schema.Literals(["read-file", "decode-document"]), |
| 34 | path: Schema.String, |
| 35 | cause: Schema.Defect(), |
| 36 | }, |
| 37 | ) { |
| 38 | override get message(): string { |
| 39 | return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | const DesktopClientSettingsWriteOperation = Schema.Literals([ |
| 44 | "create-temporary-file-name", |
| 45 | "resolve-symlink", |
| 46 | "encode-document", |
| 47 | "create-directory", |
| 48 | "write-temporary-file", |
| 49 | "replace-settings-file", |
| 50 | ]); |
| 51 | |
| 52 | export class DesktopClientSettingsWriteError extends Schema.TaggedError<DesktopClientSettingsWriteError>()( |
| 53 | "DesktopClientSettingsWriteError", |
| 54 | { |
| 55 | operation: DesktopClientSettingsWriteOperation, |
| 56 | path: Schema.String, |
| 57 | cause: Schema.Defect(), |
| 58 | }, |
| 59 | ) { |
| 60 | override get message(): string { |
| 61 | return `Desktop client settings write failed during ${this.operation} at ${this.path}.`; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export class DesktopClientSettings extends Context.Service< |
| 66 | DesktopClientSettings, |
| 67 | { |
| 68 | readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>; |
| 69 | readonly set: ( |
| 70 | settings: ClientSettings, |
| 71 | ) => Effect.Effect<void, DesktopClientSettingsWriteError>; |
| 72 | } |
| 73 | >()("@t3tools/desktop/settings/DesktopClientSettings") {} |
| 74 | |
| 75 | const readClientSettings = ( |
| 76 | fileSystem: FileSystem.FileSystem, |
| 77 | settingsPath: string, |
| 78 | ): Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError> => |
| 79 | fileSystem.readFileString(settingsPath).pipe( |
| 80 | Effect.asSome, |
| 81 | Effect.catchTags({ |
| 82 | PlatformError: (cause) => |
| 83 | cause.reason._tag === "NotFound" |
| 84 | ? Effect.succeed(Option.none<string>()) |
| 85 | : Effect.logWarning("Could not read desktop client settings.", cause).pipe( |
| 86 | Effect.annotateLogs({ settingsPath }), |
| 87 | Effect.andThen( |
| 88 | Effect.fail( |
| 89 | new DesktopClientSettingsReadError({ |
| 90 | operation: "read-file", |
| 91 | path: settingsPath, |
| 92 | cause, |
| 93 | }), |
| 94 | ), |
| 95 | ), |
| 96 | ), |
| 97 | }), |
| 98 | Effect.flatMap( |
| 99 | Option.match({ |
| 100 | onNone: () => Effect.succeed(Option.none<ClientSettings>()), |
| 101 | onSome: (raw) => |
| 102 | decodeClientSettingsJson(raw).pipe( |
| 103 | Effect.asSome, |
| 104 | Effect.catchTags({ |
| 105 | SchemaError: (cause) => |
| 106 | Effect.logWarning("Could not decode desktop client settings.", cause).pipe( |
| 107 | Effect.annotateLogs({ settingsPath }), |
| 108 | Effect.andThen( |
| 109 | Effect.fail( |
| 110 | new DesktopClientSettingsReadError({ |
| 111 | operation: "decode-document", |
| 112 | path: settingsPath, |
| 113 | cause, |
| 114 | }), |
| 115 | ), |
| 116 | ), |
| 117 | ), |
| 118 | }), |
| 119 | ), |
| 120 | }), |
| 121 | ), |
| 122 | ); |
| 123 | |
| 124 | const writeClientSettings = Effect.fnUntraced(function* (input: { |
| 125 | readonly fileSystem: FileSystem.FileSystem; |
| 126 | readonly path: Path.Path; |
| 127 | readonly settingsPath: string; |
| 128 | readonly settings: ClientSettings; |
| 129 | readonly suffix: string; |
| 130 | }): Effect.fn.Return<void, DesktopClientSettingsWriteError> { |
| 131 | const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( |
| 132 | Effect.provideService(FileSystem.FileSystem, input.fileSystem), |
| 133 | Effect.provideService(Path.Path, input.path), |
| 134 | Effect.mapError( |
| 135 | (cause) => |
| 136 | new DesktopClientSettingsWriteError({ |
| 137 | operation: "resolve-symlink", |
| 138 | path: input.settingsPath, |
| 139 | cause, |
| 140 | }), |
| 141 | ), |
| 142 | ); |
| 143 | const directory = input.path.dirname(targetPath); |
| 144 | const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; |
| 145 | const encoded = yield* encodeClientSettingsJson(input.settings).pipe( |
| 146 | Effect.mapError( |
| 147 | (cause) => |
| 148 | new DesktopClientSettingsWriteError({ |
| 149 | operation: "encode-document", |
| 150 | path: input.settingsPath, |
| 151 | cause, |
| 152 | }), |
| 153 | ), |
| 154 | ); |
| 155 | yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( |
| 156 | Effect.mapError( |
| 157 | (cause) => |
| 158 | new DesktopClientSettingsWriteError({ |
| 159 | operation: "create-directory", |
| 160 | path: directory, |
| 161 | cause, |
| 162 | }), |
| 163 | ), |
| 164 | ); |
| 165 | yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( |
| 166 | Effect.mapError( |
| 167 | (cause) => |
| 168 | new DesktopClientSettingsWriteError({ |
| 169 | operation: "write-temporary-file", |
| 170 | path: tempPath, |
| 171 | cause, |
| 172 | }), |
| 173 | ), |
| 174 | ); |
| 175 | yield* input.fileSystem.rename(tempPath, targetPath).pipe( |
| 176 | Effect.mapError( |
| 177 | (cause) => |
| 178 | new DesktopClientSettingsWriteError({ |
| 179 | operation: "replace-settings-file", |
| 180 | path: input.settingsPath, |
| 181 | cause, |
| 182 | }), |
| 183 | ), |
| 184 | ); |
| 185 | }); |
| 186 | |
| 187 | export const make = Effect.gen(function* () { |
| 188 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 189 | const fileSystem = yield* FileSystem.FileSystem; |
| 190 | const path = yield* Path.Path; |
| 191 | const crypto = yield* Crypto.Crypto; |
| 192 | |
| 193 | return DesktopClientSettings.of({ |
| 194 | get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( |
| 195 | Effect.withSpan("desktop.clientSettings.get"), |
| 196 | ), |
| 197 | set: (settings) => |
| 198 | crypto.randomUUIDv4().pipe( |
| 199 | Effect.map((uuid) => uuid.replace(/-/g, "")), |
| 200 | Effect.mapError( |
| 201 | (cause) => |
| 202 | new DesktopClientSettingsWriteError({ |
| 203 | operation: "create-temporary-file-name", |
| 204 | path: environment.clientSettingsPath, |
| 205 | cause, |
| 206 | }), |
| 207 | ), |
| 208 | Effect.flatMap((suffix) => |
| 209 | writeClientSettings({ |
| 210 | fileSystem, |
| 211 | path, |
| 212 | settingsPath: environment.clientSettingsPath, |
| 213 | settings, |
| 214 | suffix, |
| 215 | }), |
| 216 | ), |
| 217 | Effect.withSpan("desktop.clientSettings.set"), |
| 218 | ), |
| 219 | }); |
| 220 | }); |
| 221 | |
| 222 | export const layer = Layer.effect(DesktopClientSettings, make); |
| 223 | |
| 224 | export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) => |
| 225 | Layer.effect( |
| 226 | DesktopClientSettings, |
| 227 | Effect.gen(function* () { |
| 228 | const settingsRef = yield* Ref.make(initialSettings); |
| 229 | return DesktopClientSettings.of({ |
| 230 | get: Ref.get(settingsRef), |
| 231 | set: (settings) => Ref.set(settingsRef, Option.some(settings)), |
| 232 | }); |
| 233 | }), |
| 234 | ); |