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
3434 setMode: () => Effect.die("unexpected setMode"),
3535 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
3636 getAdvertisedEndpoints: Effect.succeed([]),
37} satisfies DesktopServerExposure.DesktopServerExposureShape);
37} satisfies DesktopServerExposure.DesktopServerExposure["Service"]);
3838
3939function makeEnvironmentLayer(
4040 baseDir: string,
apps/desktop/src/backend/DesktopBackendConfiguration.ts+42-45
......@@ -14,16 +14,14 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1414import * as DesktopObservability from "../app/DesktopObservability.ts";
1515import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1616
17export interface DesktopBackendConfigurationShape {
18 readonly resolve: Effect.Effect<
19 DesktopBackendManager.DesktopBackendStartConfig,
20 PlatformError.PlatformError
21 >;
22}
23
2417export class DesktopBackendConfiguration extends Context.Service<
2518 DesktopBackendConfiguration,
26 DesktopBackendConfigurationShape
19 {
20 readonly resolve: Effect.Effect<
21 DesktopBackendManager.DesktopBackendStartConfig,
22 PlatformError.PlatformError
23 >;
24 }
2725>()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}
2826
2927interface BackendObservabilitySettings {
......@@ -130,40 +128,39 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv
130128 },
131129);
132130
133export 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);
131export 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
166export const layer = Layer.effect(DesktopBackendConfiguration, make);
apps/desktop/src/backend/DesktopBackendManager.ts+51-32
......@@ -1,6 +1,5 @@
11import * as Cause from "effect/Cause";
22import * as Context from "effect/Context";
3import * as Data from "effect/Data";
43import * as Duration from "effect/Duration";
54import * as Effect from "effect/Effect";
65import * as Exit from "effect/Exit";
......@@ -16,8 +15,9 @@ import * as Schema from "effect/Schema";
1615import * as Semaphore from "effect/Semaphore";
1716import * as Scope from "effect/Scope";
1817import * as Stream from "effect/Stream";
19import { HttpClient } from "effect/unstable/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18import * as HttpClient from "effect/unstable/http/HttpClient";
19import * as ChildProcess from "effect/unstable/process/ChildProcess";
20import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
2121
2222import {
2323 DesktopBackendBootstrap,
......@@ -59,29 +59,38 @@ interface BackendProcessExit {
5959 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
6060}
6161
62export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{
63 readonly url: URL;
64}> {
65 override get message() {
62export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(
63 "BackendTimeoutError",
64 {
65 url: Schema.URL,
66 },
67) {
68 override get message(): string {
6669 return `Timed out waiting for backend readiness at ${this.url.href}.`;
6770 }
6871}
6972
70class BackendProcessBootstrapEncodeError extends Data.TaggedError(
73class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
7174 "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}`;
7782 }
7883}
7984
80class 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}`;
85class 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}`;
8594 }
8695}
8796
......@@ -106,16 +115,14 @@ export interface DesktopBackendSnapshot {
106115 readonly restartScheduled: boolean;
107116}
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
116118export class DesktopBackendManager extends Context.Service<
117119 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 }
119126>()("@t3tools/desktop/backend/DesktopBackendManager") {}
120127
121128const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } =
......@@ -230,7 +237,13 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
230237): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
231238 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
232239 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 ),
234247 );
235248 const onOutput = options.onOutput ?? (() => Effect.void);
236249 const command = ChildProcess.make(
......@@ -256,9 +269,15 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
256269 },
257270 );
258271
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 );
262281
263282 yield* options.onStarted?.(handle.pid) ?? Effect.void;
264283 if (options.captureOutput) {
......@@ -277,7 +296,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
277296 return describeProcessExit(yield* Effect.result(handle.exitCode));
278297});
279298
280const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () {
299export const make = Effect.gen(function* () {
281300 const parentScope = yield* Scope.Scope;
282301 const fileSystem = yield* FileSystem.FileSystem;
283302 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
......@@ -603,4 +622,4 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio
603622 });
604623});
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";
33import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
55import * 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
89import * as DesktopBackendManager from "./DesktopBackendManager.ts";
910import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+68-57
......@@ -1,77 +1,88 @@
11import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
22import * as Context from "effect/Context";
3import * as Data from "effect/Data";
43import * as Effect from "effect/Effect";
54import * as Layer from "effect/Layer";
65import * as Option from "effect/Option";
76import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
88import * as Semaphore from "effect/Semaphore";
9import { HttpClient } from "effect/unstable/http";
9import * as HttpClient from "effect/unstable/http/HttpClient";
1010
1111import * as DesktopBackendManager from "./DesktopBackendManager.ts";
1212
13export interface DesktopLocalEnvironmentAuthShape {
14 readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>;
13export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(
14 "DesktopLocalEnvironmentAuthBackendNotConfiguredError",
15 {},
16) {
17 override get message(): string {
18 return "Local backend is not configured.";
19 }
1520}
1621
17export class DesktopLocalEnvironmentAuthError extends Data.TaggedError(
18 "DesktopLocalEnvironmentAuthError",
19)<{
20 readonly message: string;
21 readonly cause?: unknown;
22}> {}
22export 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
31export const DesktopLocalEnvironmentAuthError = Schema.Union([
32 DesktopLocalEnvironmentAuthBackendNotConfiguredError,
33 DesktopLocalEnvironmentAuthSessionBootstrapError,
34]);
35export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type;
2336
2437export class DesktopLocalEnvironmentAuth extends Context.Service<
2538 DesktopLocalEnvironmentAuth,
26 DesktopLocalEnvironmentAuthShape
39 {
40 readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>;
41 }
2742>()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {}
2843
29export 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);
44export 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 }
3657
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"));
4484
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});
7487
75 return DesktopLocalEnvironmentAuth.of({ getBearerToken });
76 }),
77);
88export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make);
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";
77import * as Layer from "effect/Layer";
88import * as Sink from "effect/Sink";
99import * as Stream from "effect/Stream";
10import { ChildProcessSpawner } from "effect/unstable/process";
10import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1111
1212import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1313import * as DesktopConfig from "../app/DesktopConfig.ts";
14import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
1415import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1516import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1617
1718const encoder = new TextEncoder();
1819
19const emptyNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {};
20const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {
20const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {};
21const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
2122 en0: [
2223 {
2324 address: "192.168.1.20",
......@@ -27,7 +28,7 @@ const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {
2728 ],
2829};
2930
30const tailnetNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {
31const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
3132 tailscale0: [
3233 {
3334 address: "100.90.1.2",
......@@ -87,13 +88,13 @@ function makeEnvironmentLayer(baseDir: string, env: Record<string, string | unde
8788
8889function makeLayer(input: {
8990 readonly baseDir: string;
90 readonly networkInterfaces?: DesktopServerExposure.DesktopNetworkInterfaces;
91 readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces;
9192 readonly env?: Record<string, string | undefined>;
9293 readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
9394}) {
9495 const env = { T3CODE_HOME: input.baseDir, ...input.env };
9596 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
96 const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, {
97 const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, {
9798 read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
9899 });
99100
......@@ -109,7 +110,7 @@ function makeLayer(input: {
109110}
110111
111112const withHarness = <A, E, R>(
112 networkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces,
113 networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
113114 effect: Effect.Effect<
114115 A,
115116 E,
apps/desktop/src/backend/DesktopServerExposure.ts+45-79
......@@ -1,5 +1,3 @@
1import * as NodeOS from "node:os";
2
31import {
42 createAdvertisedEndpoint,
53 type CreateAdvertisedEndpointInput,
......@@ -10,41 +8,27 @@ import type {
108 DesktopServerExposureMode,
119 DesktopServerExposureState,
1210} from "@t3tools/contracts";
11import { readTailscaleStatus } from "@t3tools/tailscale";
1312import * as Context from "effect/Context";
14import * as Data from "effect/Data";
1513import * as Duration from "effect/Duration";
1614import * as Effect from "effect/Effect";
1715import * as Layer from "effect/Layer";
1816import * as Option from "effect/Option";
1917import * as Ref from "effect/Ref";
20import { HttpClient } from "effect/unstable/http";
21import { ChildProcessSpawner } from "effect/unstable/process";
18import * as Schema from "effect/Schema";
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";
2423import * as DesktopConfig from "../app/DesktopConfig.ts";
24import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
2525import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
26import { readTailscaleStatus } from "@t3tools/tailscale";
27import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
2826
2927const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);
3028
3129export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
3230const 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
4832interface ResolvedDesktopServerExposure {
4933 readonly mode: DesktopServerExposureMode;
5034 readonly bindHost: string;
......@@ -91,7 +75,7 @@ const isHttpsEndpointUrl = (value: string): boolean => {
9175};
9276
9377const resolveLanAdvertisedHost = (
94 networkInterfaces: DesktopNetworkInterfaces,
78 networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
9579 explicitHost: string | undefined,
9680): string | null => {
9781 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
......@@ -116,7 +100,7 @@ const resolveLanAdvertisedHost = (
116100const resolveDesktopServerExposure = (input: {
117101 readonly mode: DesktopServerExposureMode;
118102 readonly port: number;
119 readonly networkInterfaces: DesktopNetworkInterfaces;
103 readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
120104 readonly advertisedHostOverride?: string;
121105}): ResolvedDesktopServerExposure => {
122106 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
......@@ -218,25 +202,25 @@ const resolveDesktopCoreAdvertisedEndpoints = (
218202 return endpoints;
219203};
220204
221type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve";
222
223export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError(
205export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass<DesktopServerExposureNoNetworkAddressError>()(
224206 "DesktopServerExposureNoNetworkAddressError",
225)<{
226 readonly port: number;
227}> {
228 override get message() {
207 {
208 port: Schema.Number,
209 },
210) {
211 override get message(): string {
229212 return `No reachable network address is available for desktop network access on port ${this.port}.`;
230213 }
231214}
232215
233export class DesktopServerExposurePersistenceError extends Data.TaggedError(
216export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass<DesktopServerExposurePersistenceError>()(
234217 "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 {
240224 return `Failed to persist desktop ${this.operation} settings.`;
241225 }
242226}
......@@ -260,36 +244,25 @@ export interface DesktopServerExposureChange {
260244 readonly requiresRelaunch: boolean;
261245}
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
279247export class DesktopServerExposure extends Context.Service<
280248 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 }
282264>()("@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
293266interface RuntimeState {
294267 readonly requestedMode: DesktopServerExposureMode;
295268 readonly mode: DesktopServerExposureMode;
......@@ -311,10 +284,10 @@ interface ResolvedRuntimeState {
311284
312285const initialRuntimeState = (): RuntimeState =>
313286 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,
316289 exposure: resolveDesktopServerExposure({
317 mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
290 mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
318291 port: 0,
319292 networkInterfaces: {},
320293 }),
......@@ -348,7 +321,7 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure
348321
349322function runtimeStateFromResolvedExposure(input: {
350323 readonly requestedMode: DesktopServerExposureMode;
351 readonly settings: DesktopSettings;
324 readonly settings: DesktopAppSettings.DesktopSettings;
352325 readonly exposure: ResolvedDesktopServerExposure;
353326 readonly port: number;
354327}): RuntimeState {
......@@ -369,9 +342,9 @@ function runtimeStateFromResolvedExposure(input: {
369342
370343function resolveRuntimeState(input: {
371344 readonly requestedMode: DesktopServerExposureMode;
372 readonly settings: DesktopSettings;
345 readonly settings: DesktopAppSettings.DesktopSettings;
373346 readonly port: number;
374 readonly networkInterfaces: DesktopNetworkInterfaces;
347 readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
375348 readonly advertisedHostOverride: Option.Option<string>;
376349}): ResolvedRuntimeState {
377350 const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
......@@ -408,12 +381,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo
408381 previous.bindHost !== next.bindHost ||
409382 previous.localHttpUrl !== next.localHttpUrl;
410383
411const make = Effect.gen(function* () {
384export const make = Effect.gen(function* () {
412385 const config = yield* DesktopConfig.DesktopConfig;
413 const networkInterfaces = yield* DesktopNetworkInterfacesService;
386 const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces;
414387 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
415388 const httpClient = yield* HttpClient.HttpClient;
416 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
389 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
417390 const stateRef = yield* Ref.make(initialRuntimeState());
418391
419392 // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App
......@@ -564,10 +537,3 @@ const make = Effect.gen(function* () {
564537});
565538
566539export 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 {
99} from "@t3tools/tailscale";
1010import * as Effect from "effect/Effect";
1111import * as Option from "effect/Option";
12import { HttpClient } from "effect/unstable/http";
13import { ChildProcessSpawner } from "effect/unstable/process";
12import * as HttpClient from "effect/unstable/http/HttpClient";
13import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1414
15import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
15import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts";
1616
1717export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
1818
......@@ -25,7 +25,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
2525
2626function resolveTailscaleIpAdvertisedEndpoints(input: {
2727 readonly port: number;
28 readonly networkInterfaces: DesktopNetworkInterfaces;
28 readonly networkInterfaces: NetworkInterfaces;
2929}): readonly AdvertisedEndpoint[] {
3030 const seen = new Set<string>();
3131 const endpoints: AdvertisedEndpoint[] = [];
......@@ -103,7 +103,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
103103 readonly port: number;
104104 readonly serveEnabled?: boolean;
105105 readonly servePort?: number;
106 readonly networkInterfaces: DesktopNetworkInterfaces;
106 readonly networkInterfaces: NetworkInterfaces;
107107 readonly statusJson?: string | null;
108108 readonly readMagicDnsName?: Effect.Effect<
109109 string | null,
apps/desktop/src/main.ts+2-1
......@@ -33,6 +33,7 @@ import * as DesktopAssets from "./app/DesktopAssets.ts";
3333import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
3434import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";
3535import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts";
36import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts";
3637import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
3738import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
3839import * as DesktopShutdown from "./app/DesktopShutdown.ts";
......@@ -128,7 +129,7 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
128129);
129130
130131const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
131 Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer),
132 Layer.provideMerge(DesktopNetworkInterfaces.layer),
132133 Layer.provideMerge(desktopFoundationLayer),
133134);
134135