Unified · Split
| @@ -34,7 +34,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp · 33 unmodified lines | ||
| 34 | 34 | setMode: () => Effect.die("unexpected setMode"), |
| 35 | 35 | setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), |
| 36 | 36 | getAdvertisedEndpoints: Effect.succeed([]), |
| 37 | −} satisfies DesktopServerExposure.DesktopServerExposureShape); | |
| 37 | +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); | |
| 38 | 38 | |
| 39 | 39 | function makeEnvironmentLayer( |
| 40 | 40 | baseDir: string, |
| @@ -14,16 +14,14 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; · 13 unmodified lines | ||
| 14 | 14 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 15 | 15 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 16 | 16 | |
| 17 | −export interface DesktopBackendConfigurationShape { | |
| 18 | − readonly resolve: Effect.Effect< | |
| 19 | − DesktopBackendManager.DesktopBackendStartConfig, | |
| 20 | − PlatformError.PlatformError | |
| 21 | − >; | |
| 22 | −} | |
| 23 | − | |
| 24 | 17 | export class DesktopBackendConfiguration extends Context.Service< |
| 25 | 18 | DesktopBackendConfiguration, |
| 26 | − DesktopBackendConfigurationShape | |
| 19 | + { | |
| 20 | + readonly resolve: Effect.Effect< | |
| 21 | + DesktopBackendManager.DesktopBackendStartConfig, | |
| 22 | + PlatformError.PlatformError | |
| 23 | + >; | |
| 24 | + } | |
| 27 | 25 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} |
| 28 | 26 | |
| 29 | 27 | interface BackendObservabilitySettings { |
| @@ -130,40 +128,39 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 100 unmodified lines | ||
| 130 | 128 | }, |
| 131 | 129 | ); |
| 132 | 130 | |
| 133 | −export const layer = Layer.effect( | |
| 134 | − DesktopBackendConfiguration, | |
| 135 | − Effect.gen(function* () { | |
| 136 | − const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 137 | − const fileSystem = yield* FileSystem.FileSystem; | |
| 138 | − const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 139 | − const crypto = yield* Crypto.Crypto; | |
| 140 | − const tokenRef = yield* Ref.make(Option.none<string>()); | |
| 141 | − const getOrCreateBootstrapToken = Effect.gen(function* () { | |
| 142 | − const existing = yield* Ref.get(tokenRef); | |
| 143 | − if (Option.isSome(existing)) { | |
| 144 | − return existing.value; | |
| 145 | − } | |
| 146 | − | |
| 147 | − const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); | |
| 148 | − yield* Ref.set(tokenRef, Option.some(token)); | |
| 149 | − return token; | |
| 150 | − }); | |
| 151 | − | |
| 152 | − return DesktopBackendConfiguration.of({ | |
| 153 | − resolve: Effect.gen(function* () { | |
| 154 | − const bootstrapToken = yield* getOrCreateBootstrapToken; | |
| 155 | − const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | |
| 156 | − Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 157 | − Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 158 | − ); | |
| 159 | − return yield* resolveBackendStartConfig({ | |
| 160 | − bootstrapToken, | |
| 161 | − observabilitySettings, | |
| 162 | − }).pipe( | |
| 163 | − Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 164 | − Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | |
| 165 | − ); | |
| 166 | − }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), | |
| 167 | − }); | |
| 168 | − }), | |
| 169 | −); | |
| 131 | +export const make = Effect.gen(function* () { | |
| 132 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 133 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 134 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 135 | + const crypto = yield* Crypto.Crypto; | |
| 136 | + const tokenRef = yield* Ref.make(Option.none<string>()); | |
| 137 | + const getOrCreateBootstrapToken = Effect.gen(function* () { | |
| 138 | + const existing = yield* Ref.get(tokenRef); | |
| 139 | + if (Option.isSome(existing)) { | |
| 140 | + return existing.value; | |
| 141 | + } | |
| 142 | + | |
| 143 | + const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); | |
| 144 | + yield* Ref.set(tokenRef, Option.some(token)); | |
| 145 | + return token; | |
| 146 | + }); | |
| 147 | + | |
| 148 | + return DesktopBackendConfiguration.of({ | |
| 149 | + resolve: Effect.gen(function* () { | |
| 150 | + const bootstrapToken = yield* getOrCreateBootstrapToken; | |
| 151 | + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | |
| 152 | + Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 153 | + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 154 | + ); | |
| 155 | + return yield* resolveBackendStartConfig({ | |
| 156 | + bootstrapToken, | |
| 157 | + observabilitySettings, | |
| 158 | + }).pipe( | |
| 159 | + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 160 | + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | |
| 161 | + ); | |
| 162 | + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), | |
| 163 | + }); | |
| 164 | +}); | |
| 165 | + | |
| 166 | +export const layer = Layer.effect(DesktopBackendConfiguration, make); | |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import * as Cause from "effect/Cause"; |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | −import * as Data from "effect/Data"; | |
| 4 | 3 | import * as Duration from "effect/Duration"; |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| 6 | 5 | import * as Exit from "effect/Exit"; |
| @@ -16,8 +15,9 @@ import * as Schema from "effect/Schema"; · 9 unmodified lines | ||
| 16 | 15 | import * as Semaphore from "effect/Semaphore"; |
| 17 | 16 | import * as Scope from "effect/Scope"; |
| 18 | 17 | import * as Stream from "effect/Stream"; |
| 19 | −import { HttpClient } from "effect/unstable/http"; | |
| 20 | −import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 18 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 19 | +import * as ChildProcess from "effect/unstable/process/ChildProcess"; | |
| 20 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 21 | 21 | |
| 22 | 22 | import { |
| 23 | 23 | DesktopBackendBootstrap, |
| @@ -59,29 +59,38 @@ interface BackendProcessExit { · 35 unmodified lines | ||
| 59 | 59 | readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | −export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ | |
| 63 | − readonly url: URL; | |
| 64 | −}> { | |
| 65 | − override get message() { | |
| 62 | +export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()( | |
| 63 | + "BackendTimeoutError", | |
| 64 | + { | |
| 65 | + url: Schema.URL, | |
| 66 | + }, | |
| 67 | +) { | |
| 68 | + override get message(): string { | |
| 66 | 69 | return `Timed out waiting for backend readiness at ${this.url.href}.`; |
| 67 | 70 | } |
| 68 | 71 | } |
| 69 | 72 | |
| 70 | −class BackendProcessBootstrapEncodeError extends Data.TaggedError( | |
| 73 | +class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()( | |
| 71 | 74 | "BackendProcessBootstrapEncodeError", |
| 72 | −)<{ | |
| 73 | − readonly cause: Schema.SchemaError; | |
| 74 | −}> { | |
| 75 | − override get message() { | |
| 76 | − return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; | |
| 75 | + { | |
| 76 | + detail: Schema.String, | |
| 77 | + cause: Schema.Defect(), | |
| 78 | + }, | |
| 79 | +) { | |
| 80 | + override get message(): string { | |
| 81 | + return `Failed to encode desktop backend bootstrap payload: ${this.detail}`; | |
| 77 | 82 | } |
| 78 | 83 | } |
| 79 | 84 | |
| 80 | −class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ | |
| 81 | − readonly cause: PlatformError.PlatformError; | |
| 82 | −}> { | |
| 83 | − override get message() { | |
| 84 | − return `Failed to spawn desktop backend process: ${this.cause.message}`; | |
| 85 | +class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()( | |
| 86 | + "BackendProcessSpawnError", | |
| 87 | + { | |
| 88 | + detail: Schema.String, | |
| 89 | + cause: Schema.Defect(), | |
| 90 | + }, | |
| 91 | +) { | |
| 92 | + override get message(): string { | |
| 93 | + return `Failed to spawn desktop backend process: ${this.detail}`; | |
| 85 | 94 | } |
| 86 | 95 | } |
| 87 | 96 | |
| @@ -106,16 +115,14 @@ export interface DesktopBackendSnapshot { · 18 unmodified lines | ||
| 106 | 115 | readonly restartScheduled: boolean; |
| 107 | 116 | } |
| 108 | 117 | |
| 109 | −export interface DesktopBackendManagerShape { | |
| 110 | − readonly start: Effect.Effect<void>; | |
| 111 | − readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>; | |
| 112 | − readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>; | |
| 113 | − readonly snapshot: Effect.Effect<DesktopBackendSnapshot>; | |
| 114 | −} | |
| 115 | − | |
| 116 | 118 | export class DesktopBackendManager extends Context.Service< |
| 117 | 119 | DesktopBackendManager, |
| 118 | − DesktopBackendManagerShape | |
| 120 | + { | |
| 121 | + readonly start: Effect.Effect<void>; | |
| 122 | + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>; | |
| 123 | + readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>; | |
| 124 | + readonly snapshot: Effect.Effect<DesktopBackendSnapshot>; | |
| 125 | + } | |
| 119 | 126 | >()("@t3tools/desktop/backend/DesktopBackendManager") {} |
| 120 | 127 | |
| 121 | 128 | const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = |
| @@ -230,7 +237,13 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 108 unmodified lines | ||
| 230 | 237 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { |
| 231 | 238 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 232 | 239 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( |
| 233 | − Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), | |
| 240 | + Effect.mapError( | |
| 241 | + (cause) => | |
| 242 | + new BackendProcessBootstrapEncodeError({ | |
| 243 | + detail: cause.message, | |
| 244 | + cause, | |
| 245 | + }), | |
| 246 | + ), | |
| 234 | 247 | ); |
| 235 | 248 | const onOutput = options.onOutput ?? (() => Effect.void); |
| 236 | 249 | const command = ChildProcess.make( |
| @@ -256,9 +269,15 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 19 unmodified lines | ||
| 256 | 269 | }, |
| 257 | 270 | ); |
| 258 | 271 | |
| 259 | − const handle = yield* spawner | |
| 260 | − .spawn(command) | |
| 261 | − .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); | |
| 272 | + const handle = yield* spawner.spawn(command).pipe( | |
| 273 | + Effect.mapError( | |
| 274 | + (cause) => | |
| 275 | + new BackendProcessSpawnError({ | |
| 276 | + detail: cause.message, | |
| 277 | + cause, | |
| 278 | + }), | |
| 279 | + ), | |
| 280 | + ); | |
| 262 | 281 | |
| 263 | 282 | yield* options.onStarted?.(handle.pid) ?? Effect.void; |
| 264 | 283 | if (options.captureOutput) { |
| @@ -277,7 +296,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 12 unmodified lines | ||
| 277 | 296 | return describeProcessExit(yield* Effect.result(handle.exitCode)); |
| 278 | 297 | }); |
| 279 | 298 | |
| 280 | −const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { | |
| 299 | +export const make = Effect.gen(function* () { | |
| 281 | 300 | const parentScope = yield* Scope.Scope; |
| 282 | 301 | const fileSystem = yield* FileSystem.FileSystem; |
| 283 | 302 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| @@ -603,4 +622,4 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 319 unmodified lines | ||
| 603 | 622 | }); |
| 604 | 623 | }); |
| 605 | 624 | |
| 606 | −export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); | |
| 625 | +export const layer = Layer.effect(DesktopBackendManager, make); | |
| @@ -3,7 +3,8 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | ||
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import * as Ref from "effect/Ref"; |
| 6 | −import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 6 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 7 | +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | |
| 7 | 8 | |
| 8 | 9 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 9 | 10 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; |
| @@ -1,77 +1,88 @@ | ||
| 1 | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | −import * as Data from "effect/Data"; | |
| 4 | 3 | import * as Effect from "effect/Effect"; |
| 5 | 4 | import * as Layer from "effect/Layer"; |
| 6 | 5 | import * as Option from "effect/Option"; |
| 7 | 6 | import * as Ref from "effect/Ref"; |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | 8 | import * as Semaphore from "effect/Semaphore"; |
| 9 | −import { HttpClient } from "effect/unstable/http"; | |
| 9 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 10 | 10 | |
| 11 | 11 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 12 | 12 | |
| 13 | −export interface DesktopLocalEnvironmentAuthShape { | |
| 14 | − readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>; | |
| 13 | +export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()( | |
| 14 | + "DesktopLocalEnvironmentAuthBackendNotConfiguredError", | |
| 15 | + {}, | |
| 16 | +) { | |
| 17 | + override get message(): string { | |
| 18 | + return "Local backend is not configured."; | |
| 19 | + } | |
| 15 | 20 | } |
| 16 | 21 | |
| 17 | −export class DesktopLocalEnvironmentAuthError extends Data.TaggedError( | |
| 18 | − "DesktopLocalEnvironmentAuthError", | |
| 19 | −)<{ | |
| 20 | − readonly message: string; | |
| 21 | − readonly cause?: unknown; | |
| 22 | −}> {} | |
| 22 | +export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthSessionBootstrapError>()( | |
| 23 | + "DesktopLocalEnvironmentAuthSessionBootstrapError", | |
| 24 | + { cause: Schema.Defect() }, | |
| 25 | +) { | |
| 26 | + override get message(): string { | |
| 27 | + return "Failed to create the local desktop bearer session."; | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const DesktopLocalEnvironmentAuthError = Schema.Union([ | |
| 32 | + DesktopLocalEnvironmentAuthBackendNotConfiguredError, | |
| 33 | + DesktopLocalEnvironmentAuthSessionBootstrapError, | |
| 34 | +]); | |
| 35 | +export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type; | |
| 23 | 36 | |
| 24 | 37 | export class DesktopLocalEnvironmentAuth extends Context.Service< |
| 25 | 38 | DesktopLocalEnvironmentAuth, |
| 26 | − DesktopLocalEnvironmentAuthShape | |
| 39 | + { | |
| 40 | + readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>; | |
| 41 | + } | |
| 27 | 42 | >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} |
| 28 | 43 | |
| 29 | −export const layer = Layer.effect( | |
| 30 | − DesktopLocalEnvironmentAuth, | |
| 31 | − Effect.gen(function* () { | |
| 32 | − const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 33 | − const httpClient = yield* HttpClient.HttpClient; | |
| 34 | − const tokenRef = yield* Ref.make(Option.none<string>()); | |
| 35 | − const mutex = yield* Semaphore.make(1); | |
| 44 | +export const make = Effect.gen(function* () { | |
| 45 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 46 | + const httpClient = yield* HttpClient.HttpClient; | |
| 47 | + const tokenRef = yield* Ref.make(Option.none<string>()); | |
| 48 | + const mutex = yield* Semaphore.make(1); | |
| 49 | + | |
| 50 | + const getBearerToken = mutex | |
| 51 | + .withPermits(1)( | |
| 52 | + Effect.gen(function* () { | |
| 53 | + const cached = yield* Ref.get(tokenRef); | |
| 54 | + if (Option.isSome(cached)) { | |
| 55 | + return cached.value; | |
| 56 | + } | |
| 36 | 57 | |
| 37 | − const getBearerToken = mutex | |
| 38 | − .withPermits(1)( | |
| 39 | − Effect.gen(function* () { | |
| 40 | − const cached = yield* Ref.get(tokenRef); | |
| 41 | − if (Option.isSome(cached)) { | |
| 42 | − return cached.value; | |
| 43 | − } | |
| 58 | + const configOption = yield* backendManager.currentConfig; | |
| 59 | + if (Option.isNone(configOption)) { | |
| 60 | + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); | |
| 61 | + } | |
| 62 | + const config = configOption.value; | |
| 63 | + const session = yield* bootstrapRemoteBearerSession({ | |
| 64 | + httpBaseUrl: config.httpBaseUrl.href, | |
| 65 | + credential: config.bootstrap.desktopBootstrapToken, | |
| 66 | + clientMetadata: { | |
| 67 | + label: "T3 Code Desktop", | |
| 68 | + deviceType: "desktop", | |
| 69 | + }, | |
| 70 | + }).pipe( | |
| 71 | + Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 72 | + Effect.mapError( | |
| 73 | + (cause) => | |
| 74 | + new DesktopLocalEnvironmentAuthSessionBootstrapError({ | |
| 75 | + cause, | |
| 76 | + }), | |
| 77 | + ), | |
| 78 | + ); | |
| 79 | + yield* Ref.set(tokenRef, Option.some(session.access_token)); | |
| 80 | + return session.access_token; | |
| 81 | + }), | |
| 82 | + ) | |
| 83 | + .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken")); | |
| 44 | 84 | |
| 45 | − const configOption = yield* backendManager.currentConfig; | |
| 46 | − if (Option.isNone(configOption)) { | |
| 47 | − return yield* new DesktopLocalEnvironmentAuthError({ | |
| 48 | − message: "Local backend is not configured.", | |
| 49 | − }); | |
| 50 | − } | |
| 51 | − const config = configOption.value; | |
| 52 | − const session = yield* bootstrapRemoteBearerSession({ | |
| 53 | − httpBaseUrl: config.httpBaseUrl.href, | |
| 54 | − credential: config.bootstrap.desktopBootstrapToken, | |
| 55 | − clientMetadata: { | |
| 56 | − label: "T3 Code Desktop", | |
| 57 | − deviceType: "desktop", | |
| 58 | − }, | |
| 59 | − }).pipe( | |
| 60 | − Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 61 | − Effect.mapError( | |
| 62 | − (cause) => | |
| 63 | − new DesktopLocalEnvironmentAuthError({ | |
| 64 | − message: "Failed to create the local desktop bearer session.", | |
| 65 | − cause, | |
| 66 | − }), | |
| 67 | − ), | |
| 68 | − ); | |
| 69 | − yield* Ref.set(tokenRef, Option.some(session.access_token)); | |
| 70 | − return session.access_token; | |
| 71 | − }), | |
| 72 | − ) | |
| 73 | − .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken")); | |
| 85 | + return DesktopLocalEnvironmentAuth.of({ getBearerToken }); | |
| 86 | +}); | |
| 74 | 87 | |
| 75 | − return DesktopLocalEnvironmentAuth.of({ getBearerToken }); | |
| 76 | − }), | |
| 77 | −); | |
| 88 | +export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make); | |
| @@ -0,0 +1,33 @@ | ||
| 1 | +import { networkInterfaces } from "node:os"; | |
| 2 | + | |
| 3 | +import * as Context from "effect/Context"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | + | |
| 7 | +export interface DesktopNetworkInterfaceInfo { | |
| 8 | + readonly address: string; | |
| 9 | + readonly family: string | number; | |
| 10 | + readonly internal: boolean; | |
| 11 | + readonly netmask?: string; | |
| 12 | + readonly mac?: string; | |
| 13 | + readonly cidr?: string | null; | |
| 14 | + readonly scopeid?: number; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export type NetworkInterfaces = Readonly< | |
| 18 | + Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined> | |
| 19 | +>; | |
| 20 | + | |
| 21 | +export class DesktopNetworkInterfaces extends Context.Service< | |
| 22 | + DesktopNetworkInterfaces, | |
| 23 | + { | |
| 24 | + readonly read: Effect.Effect<NetworkInterfaces>; | |
| 25 | + } | |
| 26 | +>()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} | |
| 27 | + | |
| 28 | +export const make = (): DesktopNetworkInterfaces["Service"] => | |
| 29 | + DesktopNetworkInterfaces.of({ | |
| 30 | + read: Effect.sync(() => networkInterfaces()), | |
| 31 | + }); | |
| 32 | + | |
| 33 | +export const layer = Layer.succeed(DesktopNetworkInterfaces, make()); | |
| @@ -7,17 +7,18 @@ import * as FileSystem from "effect/FileSystem"; · 6 unmodified lines | ||
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Sink from "effect/Sink"; |
| 9 | 9 | import * as Stream from "effect/Stream"; |
| 10 | −import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 10 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 11 | 11 | |
| 12 | 12 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 13 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 14 | +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; | |
| 14 | 15 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 15 | 16 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 16 | 17 | |
| 17 | 18 | const encoder = new TextEncoder(); |
| 18 | 19 | |
| 19 | −const emptyNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {}; | |
| 20 | −const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { | |
| 20 | +const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; | |
| 21 | +const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { | |
| 21 | 22 | en0: [ |
| 22 | 23 | { |
| 23 | 24 | address: "192.168.1.20", |
| @@ -27,7 +28,7 @@ const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { · 3 unmodified lines | ||
| 27 | 28 | ], |
| 28 | 29 | }; |
| 29 | 30 | |
| 30 | −const tailnetNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { | |
| 31 | +const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { | |
| 31 | 32 | tailscale0: [ |
| 32 | 33 | { |
| 33 | 34 | address: "100.90.1.2", |
| @@ -87,13 +88,13 @@ function makeEnvironmentLayer(baseDir: string, env: Record<string, string | unde · 53 unmodified lines | ||
| 87 | 88 | |
| 88 | 89 | function makeLayer(input: { |
| 89 | 90 | readonly baseDir: string; |
| 90 | − readonly networkInterfaces?: DesktopServerExposure.DesktopNetworkInterfaces; | |
| 91 | + readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; | |
| 91 | 92 | readonly env?: Record<string, string | undefined>; |
| 92 | 93 | readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; |
| 93 | 94 | }) { |
| 94 | 95 | const env = { T3CODE_HOME: input.baseDir, ...input.env }; |
| 95 | 96 | const environmentLayer = makeEnvironmentLayer(input.baseDir, env); |
| 96 | − const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { | |
| 97 | + const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { | |
| 97 | 98 | read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), |
| 98 | 99 | }); |
| 99 | 100 | |
| @@ -109,7 +110,7 @@ function makeLayer(input: { · 9 unmodified lines | ||
| 109 | 110 | } |
| 110 | 111 | |
| 111 | 112 | const withHarness = <A, E, R>( |
| 112 | − networkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces, | |
| 113 | + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, | |
| 113 | 114 | effect: Effect.Effect< |
| 114 | 115 | A, |
| 115 | 116 | E, |
| @@ -1,5 +1,3 @@ | ||
| 1 | −import * as NodeOS from "node:os"; | |
| 2 | − | |
| 3 | 1 | import { |
| 4 | 2 | createAdvertisedEndpoint, |
| 5 | 3 | type CreateAdvertisedEndpointInput, |
| @@ -10,41 +8,27 @@ import type { · 4 unmodified lines | ||
| 10 | 8 | DesktopServerExposureMode, |
| 11 | 9 | DesktopServerExposureState, |
| 12 | 10 | } from "@t3tools/contracts"; |
| 11 | +import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 13 | 12 | import * as Context from "effect/Context"; |
| 14 | −import * as Data from "effect/Data"; | |
| 15 | 13 | import * as Duration from "effect/Duration"; |
| 16 | 14 | import * as Effect from "effect/Effect"; |
| 17 | 15 | import * as Layer from "effect/Layer"; |
| 18 | 16 | import * as Option from "effect/Option"; |
| 19 | 17 | import * as Ref from "effect/Ref"; |
| 20 | −import { HttpClient } from "effect/unstable/http"; | |
| 21 | −import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 18 | +import * as Schema from "effect/Schema"; | |
| 19 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 20 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 22 | 21 | |
| 23 | −import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; | |
| 22 | +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 24 | 23 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 24 | +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; | |
| 25 | 25 | import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; |
| 26 | −import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 27 | −import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; | |
| 28 | 26 | |
| 29 | 27 | const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); |
| 30 | 28 | |
| 31 | 29 | export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 32 | 30 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; |
| 33 | 31 | |
| 34 | −export interface DesktopNetworkInterfaceInfo { | |
| 35 | − readonly address: string; | |
| 36 | − readonly family: string | number; | |
| 37 | − readonly internal: boolean; | |
| 38 | − readonly netmask?: string; | |
| 39 | − readonly mac?: string; | |
| 40 | − readonly cidr?: string | null; | |
| 41 | − readonly scopeid?: number; | |
| 42 | −} | |
| 43 | − | |
| 44 | −export type DesktopNetworkInterfaces = Readonly< | |
| 45 | − Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined> | |
| 46 | −>; | |
| 47 | − | |
| 48 | 32 | interface ResolvedDesktopServerExposure { |
| 49 | 33 | readonly mode: DesktopServerExposureMode; |
| 50 | 34 | readonly bindHost: string; |
| @@ -91,7 +75,7 @@ const isHttpsEndpointUrl = (value: string): boolean => { · 40 unmodified lines | ||
| 91 | 75 | }; |
| 92 | 76 | |
| 93 | 77 | const resolveLanAdvertisedHost = ( |
| 94 | − networkInterfaces: DesktopNetworkInterfaces, | |
| 78 | + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, | |
| 95 | 79 | explicitHost: string | undefined, |
| 96 | 80 | ): string | null => { |
| 97 | 81 | const normalizedExplicitHost = normalizeOptionalHost(explicitHost); |
| @@ -116,7 +100,7 @@ const resolveLanAdvertisedHost = ( · 18 unmodified lines | ||
| 116 | 100 | const resolveDesktopServerExposure = (input: { |
| 117 | 101 | readonly mode: DesktopServerExposureMode; |
| 118 | 102 | readonly port: number; |
| 119 | − readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 103 | + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; | |
| 120 | 104 | readonly advertisedHostOverride?: string; |
| 121 | 105 | }): ResolvedDesktopServerExposure => { |
| 122 | 106 | const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; |
| @@ -218,25 +202,25 @@ const resolveDesktopCoreAdvertisedEndpoints = ( · 95 unmodified lines | ||
| 218 | 202 | return endpoints; |
| 219 | 203 | }; |
| 220 | 204 | |
| 221 | −type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; | |
| 222 | − | |
| 223 | −export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( | |
| 205 | +export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass<DesktopServerExposureNoNetworkAddressError>()( | |
| 224 | 206 | "DesktopServerExposureNoNetworkAddressError", |
| 225 | −)<{ | |
| 226 | − readonly port: number; | |
| 227 | −}> { | |
| 228 | − override get message() { | |
| 207 | + { | |
| 208 | + port: Schema.Number, | |
| 209 | + }, | |
| 210 | +) { | |
| 211 | + override get message(): string { | |
| 229 | 212 | return `No reachable network address is available for desktop network access on port ${this.port}.`; |
| 230 | 213 | } |
| 231 | 214 | } |
| 232 | 215 | |
| 233 | −export class DesktopServerExposurePersistenceError extends Data.TaggedError( | |
| 216 | +export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass<DesktopServerExposurePersistenceError>()( | |
| 234 | 217 | "DesktopServerExposurePersistenceError", |
| 235 | −)<{ | |
| 236 | − readonly operation: DesktopServerExposurePersistenceOperation; | |
| 237 | − readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; | |
| 238 | −}> { | |
| 239 | − override get message() { | |
| 218 | + { | |
| 219 | + operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]), | |
| 220 | + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), | |
| 221 | + }, | |
| 222 | +) { | |
| 223 | + override get message(): string { | |
| 240 | 224 | return `Failed to persist desktop ${this.operation} settings.`; |
| 241 | 225 | } |
| 242 | 226 | } |
| @@ -260,36 +244,25 @@ export interface DesktopServerExposureChange { · 17 unmodified lines | ||
| 260 | 244 | readonly requiresRelaunch: boolean; |
| 261 | 245 | } |
| 262 | 246 | |
| 263 | −export interface DesktopServerExposureShape { | |
| 264 | − readonly getState: Effect.Effect<DesktopServerExposureState>; | |
| 265 | − readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>; | |
| 266 | − readonly configureFromSettings: (input: { | |
| 267 | − readonly port: number; | |
| 268 | − }) => Effect.Effect<DesktopServerExposureState>; | |
| 269 | − readonly setMode: ( | |
| 270 | − mode: DesktopServerExposureMode, | |
| 271 | − ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>; | |
| 272 | − readonly setTailscaleServeEnabled: (input: { | |
| 273 | − readonly enabled: boolean; | |
| 274 | − readonly port?: number; | |
| 275 | − }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>; | |
| 276 | − readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>; | |
| 277 | −} | |
| 278 | − | |
| 279 | 247 | export class DesktopServerExposure extends Context.Service< |
| 280 | 248 | DesktopServerExposure, |
| 281 | − DesktopServerExposureShape | |
| 249 | + { | |
| 250 | + readonly getState: Effect.Effect<DesktopServerExposureState>; | |
| 251 | + readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>; | |
| 252 | + readonly configureFromSettings: (input: { | |
| 253 | + readonly port: number; | |
| 254 | + }) => Effect.Effect<DesktopServerExposureState>; | |
| 255 | + readonly setMode: ( | |
| 256 | + mode: DesktopServerExposureMode, | |
| 257 | + ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>; | |
| 258 | + readonly setTailscaleServeEnabled: (input: { | |
| 259 | + readonly enabled: boolean; | |
| 260 | + readonly port?: number; | |
| 261 | + }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>; | |
| 262 | + readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>; | |
| 263 | + } | |
| 282 | 264 | >()("@t3tools/desktop/backend/DesktopServerExposure") {} |
| 283 | 265 | |
| 284 | −export interface DesktopNetworkInterfacesServiceShape { | |
| 285 | − readonly read: Effect.Effect<DesktopNetworkInterfaces>; | |
| 286 | −} | |
| 287 | − | |
| 288 | −export class DesktopNetworkInterfacesService extends Context.Service< | |
| 289 | − DesktopNetworkInterfacesService, | |
| 290 | − DesktopNetworkInterfacesServiceShape | |
| 291 | −>()("@t3tools/desktop/backend/DesktopServerExposure/DesktopNetworkInterfacesService") {} | |
| 292 | − | |
| 293 | 266 | interface RuntimeState { |
| 294 | 267 | readonly requestedMode: DesktopServerExposureMode; |
| 295 | 268 | readonly mode: DesktopServerExposureMode; |
| @@ -311,10 +284,10 @@ interface ResolvedRuntimeState { · 15 unmodified lines | ||
| 311 | 284 | |
| 312 | 285 | const initialRuntimeState = (): RuntimeState => |
| 313 | 286 | runtimeStateFromResolvedExposure({ |
| 314 | − requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 315 | − settings: DEFAULT_DESKTOP_SETTINGS, | |
| 287 | + requestedMode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 288 | + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 316 | 289 | exposure: resolveDesktopServerExposure({ |
| 317 | − mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 290 | + mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 318 | 291 | port: 0, |
| 319 | 292 | networkInterfaces: {}, |
| 320 | 293 | }), |
| @@ -348,7 +321,7 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure · 27 unmodified lines | ||
| 348 | 321 | |
| 349 | 322 | function runtimeStateFromResolvedExposure(input: { |
| 350 | 323 | readonly requestedMode: DesktopServerExposureMode; |
| 351 | − readonly settings: DesktopSettings; | |
| 324 | + readonly settings: DesktopAppSettings.DesktopSettings; | |
| 352 | 325 | readonly exposure: ResolvedDesktopServerExposure; |
| 353 | 326 | readonly port: number; |
| 354 | 327 | }): RuntimeState { |
| @@ -369,9 +342,9 @@ function runtimeStateFromResolvedExposure(input: { · 14 unmodified lines | ||
| 369 | 342 | |
| 370 | 343 | function resolveRuntimeState(input: { |
| 371 | 344 | readonly requestedMode: DesktopServerExposureMode; |
| 372 | − readonly settings: DesktopSettings; | |
| 345 | + readonly settings: DesktopAppSettings.DesktopSettings; | |
| 373 | 346 | readonly port: number; |
| 374 | − readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 347 | + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; | |
| 375 | 348 | readonly advertisedHostOverride: Option.Option<string>; |
| 376 | 349 | }): ResolvedRuntimeState { |
| 377 | 350 | const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); |
| @@ -408,12 +381,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo · 30 unmodified lines | ||
| 408 | 381 | previous.bindHost !== next.bindHost || |
| 409 | 382 | previous.localHttpUrl !== next.localHttpUrl; |
| 410 | 383 | |
| 411 | −const make = Effect.gen(function* () { | |
| 384 | +export const make = Effect.gen(function* () { | |
| 412 | 385 | const config = yield* DesktopConfig.DesktopConfig; |
| 413 | − const networkInterfaces = yield* DesktopNetworkInterfacesService; | |
| 386 | + const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; | |
| 414 | 387 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 415 | 388 | const httpClient = yield* HttpClient.HttpClient; |
| 416 | − const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; | |
| 389 | + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 417 | 390 | const stateRef = yield* Ref.make(initialRuntimeState()); |
| 418 | 391 | |
| 419 | 392 | // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App |
| @@ -564,10 +537,3 @@ const make = Effect.gen(function* () { · 144 unmodified lines | ||
| 564 | 537 | }); |
| 565 | 538 | |
| 566 | 539 | export const layer = Layer.effect(DesktopServerExposure, make); |
| 567 | − | |
| 568 | −export const networkInterfacesLayer = Layer.succeed( | |
| 569 | − DesktopNetworkInterfacesService, | |
| 570 | − DesktopNetworkInterfacesService.of({ | |
| 571 | − read: Effect.sync(() => NodeOS.networkInterfaces()), | |
| 572 | − }), | |
| 573 | −); | |
| @@ -9,10 +9,10 @@ import { · 8 unmodified lines | ||
| 9 | 9 | } from "@t3tools/tailscale"; |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | import * as Option from "effect/Option"; |
| 12 | −import { HttpClient } from "effect/unstable/http"; | |
| 13 | −import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 12 | +import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 13 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 14 | 14 | |
| 15 | −import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; | |
| 15 | +import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; | |
| 16 | 16 | |
| 17 | 17 | export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; |
| 18 | 18 | |
| @@ -25,7 +25,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { · 6 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | function resolveTailscaleIpAdvertisedEndpoints(input: { |
| 27 | 27 | readonly port: number; |
| 28 | − readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 28 | + readonly networkInterfaces: NetworkInterfaces; | |
| 29 | 29 | }): readonly AdvertisedEndpoint[] { |
| 30 | 30 | const seen = new Set<string>(); |
| 31 | 31 | const endpoints: AdvertisedEndpoint[] = []; |
| @@ -103,7 +103,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd · 71 unmodified lines | ||
| 103 | 103 | readonly port: number; |
| 104 | 104 | readonly serveEnabled?: boolean; |
| 105 | 105 | readonly servePort?: number; |
| 106 | − readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 106 | + readonly networkInterfaces: NetworkInterfaces; | |
| 107 | 107 | readonly statusJson?: string | null; |
| 108 | 108 | readonly readMagicDnsName?: Effect.Effect< |
| 109 | 109 | string | null, |
| @@ -33,6 +33,7 @@ import * as DesktopAssets from "./app/DesktopAssets.ts"; · 32 unmodified lines | ||
| 33 | 33 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| 34 | 34 | import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; |
| 35 | 35 | import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; |
| 36 | +import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; | |
| 36 | 37 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; |
| 37 | 38 | import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; |
| 38 | 39 | import * as DesktopShutdown from "./app/DesktopShutdown.ts"; |
| @@ -128,7 +129,7 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( · 89 unmodified lines | ||
| 128 | 129 | ); |
| 129 | 130 | |
| 130 | 131 | const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( |
| 131 | − Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), | |
| 132 | + Layer.provideMerge(DesktopNetworkInterfaces.layer), | |
| 132 | 133 | Layer.provideMerge(desktopFoundationLayer), |
| 133 | 134 | ); |
| 134 | 135 | |