diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 6bfe2e097aeb36779cee033673e3d1f59576d3f7..8b934fd8d85c7f98179a900b13783c2ddd9a253f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -91,6 +91,7 @@ function makeLayer(input: { readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; readonly env?: Record; readonly spawnerLayer?: Layer.Layer; + readonly desktopSettingsLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); @@ -99,7 +100,7 @@ function makeLayer(input: { }); return DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), @@ -122,6 +123,7 @@ const withHarness = ( >, env: Record = {}, spawnerLayer?: Layer.Layer, + desktopSettingsLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -135,6 +137,7 @@ const withHarness = ( networkInterfaces, env, ...(spawnerLayer ? { spawnerLayer } : {}), + ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), }), ), ); @@ -237,6 +240,67 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("preserves persistence request context and the settings failure chain", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/desktop-settings.json", + cause: diskFailure, + }); + const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.fail(settingsFailure), + setTailscaleServe: () => Effect.fail(settingsFailure), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + return withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.instanceOf( + modeError, + DesktopServerExposure.DesktopServerExposureModePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); + assert.equal(modeError.mode, "network-accessible"); + assert.strictEqual(modeError.cause, settingsFailure); + assert.strictEqual(modeError.cause.cause, diskFailure); + assert.equal( + modeError.message, + "Failed to persist desktop server exposure mode network-accessible.", + ); + assert.notInclude(modeError.message, diskFailure.message); + + const tailscaleError = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 8443 }) + .pipe(Effect.flip); + assert.instanceOf( + tailscaleError, + DesktopServerExposure.DesktopTailscaleServePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); + assert.equal(tailscaleError.enabled, true); + assert.equal(tailscaleError.port, 8443); + assert.strictEqual(tailscaleError.cause, settingsFailure); + assert.strictEqual(tailscaleError.cause.cause, diskFailure); + assert.equal( + tailscaleError.message, + "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", + ); + assert.notInclude(tailscaleError.message, diskFailure.message); + }), + {}, + undefined, + settingsLayer, + ); + }); + it.effect("resolves advertised endpoints from the scoped runtime state", () => withHarness( { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 64e65a61c77e9f2c40a4b6e0795d921e42af301d..f04d2af7b1f67f707b4ac53b567225176f09fc0f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -2,11 +2,12 @@ import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, } from "@t3tools/shared/advertisedEndpoint"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, - DesktopServerExposureState, +import { + DesktopServerExposureModeSchema, + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, + type DesktopServerExposureMode, + type DesktopServerExposureState, } from "@t3tools/contracts"; import { readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; @@ -213,23 +214,45 @@ export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErr } } -export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass()( - "DesktopServerExposurePersistenceError", +export class DesktopServerExposureModePersistenceError extends Schema.TaggedErrorClass()( + "DesktopServerExposureModePersistenceError", { - operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]), + mode: DesktopServerExposureModeSchema, cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), }, ) { override get message(): string { - return `Failed to persist desktop ${this.operation} settings.`; + return `Failed to persist desktop server exposure mode ${this.mode}.`; } } -export type DesktopServerExposureSetModeError = - | DesktopServerExposureNoNetworkAddressError - | DesktopServerExposurePersistenceError; +export class DesktopTailscaleServePersistenceError extends Schema.TaggedErrorClass()( + "DesktopTailscaleServePersistenceError", + { + enabled: Schema.Boolean, + port: Schema.NullOr(Schema.Number), + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`; + } +} + +export const DesktopServerExposureSetModeError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, +]); +export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; +export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); -export type DesktopServerExposureError = DesktopServerExposureSetModeError; +export const DesktopServerExposureError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, + DesktopTailscaleServePersistenceError, +]); +export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; +export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; @@ -258,7 +281,7 @@ export class DesktopServerExposure extends Context.Service< readonly setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getAdvertisedEndpoints: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopServerExposure") {} @@ -449,8 +472,8 @@ export const make = Effect.gen(function* () { const change = yield* desktopSettings.setServerExposureMode(mode).pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "server-exposure-mode", + new DesktopServerExposureModePersistenceError({ + mode, cause, }), ), @@ -477,8 +500,9 @@ export const make = Effect.gen(function* () { .pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "tailscale-serve", + new DesktopTailscaleServePersistenceError({ + enabled: input.enabled, + port: input.port ?? null, cause, }), ),