authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 21:23:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 21:23:54-07:00
logffae12f329c4de9680efd1757b488a3b5bbe3cd2
tree74e065115f173b185201662e1bfcd87df2a8c5d7
parent01cd564557c2e96019f7e7060869097bbe4f6805
signature Signed by PGP key B5690EEEBB952194

[codex] refactor desktop backend Effect services (#3192)

Co-authored-by: codex <codex@users.noreply.github.com>

10 files changed, 257 insertions(+), 228 deletions(-)

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