authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-07 21:17:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-07 21:17:08-07:00
logaa219be713e534fa79a179e2cffdd773caa9cdc5
tree14d6cbb98f3ba543dced0b05961ff4b3c94899e1
parentdd32f52655d08d1cbab9149ac44b0a63665779db
signature Signed by PGP key B5690EEEBB952194

port desktop app to Effect (#2546)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: codex <codex@users.noreply.github.com>

190 files changed, 14118 insertions(+), 7654 deletions(-)

.gitignore+2
...@@ -23,3 +23,5 @@ __screenshots__/...@@ -23,3 +23,5 @@ __screenshots__/
23.tanstack23.tanstack
24squashfs-root/24squashfs-root/
25.vercel25.vercel
26dist-electron/
27.electron-runtime/
apps/desktop/.gitignore deleted-2
...@@ -1,2 +0,0 @@
1dist-electron/
2.electron-runtime/
apps/desktop/package.json+3-1
...@@ -17,10 +17,12 @@...@@ -17,10 +17,12 @@
17 "dependencies": {17 "dependencies": {
18 "@effect/platform-node": "catalog:",18 "@effect/platform-node": "catalog:",
19 "effect": "catalog:",19 "effect": "catalog:",
20 "electron": "40.9.3",20 "electron": "41.5.0",
21 "electron-updater": "^6.6.2"21 "electron-updater": "^6.6.2"
22 },22 },
23 "devDependencies": {23 "devDependencies": {
24 "@effect/language-service": "catalog:",
25 "@effect/vitest": "catalog:",
24 "@t3tools/client-runtime": "workspace:*",26 "@t3tools/client-runtime": "workspace:*",
25 "@t3tools/contracts": "workspace:*",27 "@t3tools/contracts": "workspace:*",
26 "@t3tools/shared": "workspace:*",28 "@t3tools/shared": "workspace:*",
apps/desktop/src/app/DesktopApp.ts created+240
...@@ -0,0 +1,240 @@
1import * as Cause from "effect/Cause";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Random from "effect/Random";
6import * as Ref from "effect/Ref";
7
8import * as NetService from "@t3tools/shared/Net";
9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
15import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
16import * as DesktopEnvironment from "./DesktopEnvironment.ts";
17import * as DesktopLifecycle from "./DesktopLifecycle.ts";
18import * as DesktopObservability from "./DesktopObservability.ts";
19import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
20import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
21import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
22import * as DesktopState from "./DesktopState.ts";
23import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
24
25const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
26const MAX_TCP_PORT = 65_535;
27const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const;
28
29const makeDesktopRunId = Random.nextUUIDv4.pipe(
30 Effect.map((value) => value.replaceAll("-", "").slice(0, 12)),
31);
32
33class DesktopBackendPortUnavailableError extends Data.TaggedError(
34 "DesktopBackendPortUnavailableError",
35)<{
36 readonly startPort: number;
37 readonly maxPort: number;
38 readonly hosts: readonly string[];
39}> {
40 override get message() {
41 return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`;
42 }
43}
44
45class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError(
46 "DesktopDevelopmentBackendPortRequiredError",
47)<{}> {
48 override get message() {
49 return "T3CODE_PORT is required in desktop development.";
50 }
51}
52
53const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
54 DesktopObservability.makeComponentLogger("desktop-bootstrap");
55
56const { logInfo: logStartupInfo, logError: logStartupError } =
57 DesktopObservability.makeComponentLogger("desktop-startup");
58
59const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (
60 configuredPort: Option.Option<number>,
61) {
62 if (Option.isSome(configuredPort)) {
63 return {
64 port: configuredPort.value,
65 selectedByScan: false,
66 } as const;
67 }
68
69 const net = yield* NetService.NetService;
70 for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) {
71 let availableOnEveryHost = true;
72
73 for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) {
74 if (!(yield* net.canListenOnHost(port, host))) {
75 availableOnEveryHost = false;
76 break;
77 }
78 }
79
80 if (availableOnEveryHost) {
81 return {
82 port,
83 selectedByScan: true,
84 } as const;
85 }
86 }
87
88 return yield* new DesktopBackendPortUnavailableError({
89 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
90 maxPort: MAX_TCP_PORT,
91 hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS,
92 });
93});
94
95const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* (
96 stage: string,
97 error: unknown,
98): Effect.fn.Return<
99 void,
100 never,
101 | DesktopLifecycle.DesktopShutdown
102 | DesktopState.DesktopState
103 | ElectronApp.ElectronApp
104 | ElectronDialog.ElectronDialog
105> {
106 const shutdown = yield* DesktopLifecycle.DesktopShutdown;
107 const state = yield* DesktopState.DesktopState;
108 const electronApp = yield* ElectronApp.ElectronApp;
109 const electronDialog = yield* ElectronDialog.ElectronDialog;
110 const message = error instanceof Error ? error.message : String(error);
111 const detail =
112 error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";
113 yield* logStartupError("fatal startup error", {
114 stage,
115 message,
116 ...(detail.length > 0 ? { detail } : {}),
117 });
118 const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
119 if (!wasQuitting) {
120 yield* electronDialog.showErrorBox(
121 "T3 Code failed to start",
122 `Stage: ${stage}\n${message}${detail}`,
123 );
124 }
125 yield* shutdown.request;
126 yield* electronApp.quit;
127});
128
129const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
130 handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));
131
132const bootstrap = Effect.gen(function* () {
133 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
134 const state = yield* DesktopState.DesktopState;
135 const environment = yield* DesktopEnvironment.DesktopEnvironment;
136 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
137 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
138 yield* logBootstrapInfo("bootstrap start");
139
140 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
141 return yield* new DesktopDevelopmentBackendPortRequiredError();
142 }
143
144 const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
145 const backendPort = backendPortSelection.port;
146 yield* logBootstrapInfo(
147 backendPortSelection.selectedByScan
148 ? "selected backend port via sequential scan"
149 : "using configured backend port",
150 {
151 port: backendPort,
152 ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}),
153 },
154 );
155
156 const settings = yield* desktopSettings.get;
157 if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
158 yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
159 mode: settings.serverExposureMode,
160 });
161 }
162 const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
163 const backendConfig = yield* serverExposure.backendConfig;
164 yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
165 baseUrl: backendConfig.httpBaseUrl.href,
166 });
167 if (serverExposureState.endpointUrl) {
168 yield* logBootstrapInfo("bootstrap enabled network access", {
169 endpointUrl: serverExposureState.endpointUrl,
170 });
171 } else if (settings.serverExposureMode === "network-accessible") {
172 yield* logBootstrapWarning(
173 "bootstrap fell back to local-only because no advertised network host was available",
174 );
175 }
176
177 yield* installDesktopIpcHandlers;
178 yield* logBootstrapInfo("bootstrap ipc handlers registered");
179
180 if (!(yield* Ref.get(state.quitting))) {
181 yield* backendManager.start;
182 yield* logBootstrapInfo("bootstrap backend start requested");
183 }
184}).pipe(Effect.withSpan("desktop.bootstrap"));
185
186const startup = Effect.gen(function* () {
187 const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity;
188 const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
189 const electronApp = yield* ElectronApp.ElectronApp;
190 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
191 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
192 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
193 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
194 const updates = yield* DesktopUpdates.DesktopUpdates;
195 const environment = yield* DesktopEnvironment.DesktopEnvironment;
196
197 yield* shellEnvironment.installIntoProcess;
198 const userDataPath = yield* appIdentity.resolveUserDataPath;
199 yield* electronApp.setPath("userData", userDataPath);
200 yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
201 yield* desktopSettings.load;
202
203 if (environment.platform === "linux") {
204 yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass);
205 }
206
207 yield* appIdentity.configure;
208 yield* lifecycle.register;
209
210 yield* electronApp.whenReady.pipe(
211 Effect.withSpan("desktop.electron.whenReady"),
212 Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
213 );
214 yield* logStartupInfo("app ready");
215 yield* appIdentity.configure;
216 yield* applicationMenu.configure;
217 yield* electronProtocol.registerDesktopFileProtocol;
218 yield* updates.configure;
219 yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
220}).pipe(Effect.withSpan("desktop.startup"));
221
222const scopedProgram = Effect.scoped(
223 Effect.gen(function* () {
224 const runId = yield* makeDesktopRunId;
225 yield* Effect.annotateLogsScoped({ scope: "desktop", runId });
226 yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });
227
228 const shutdown = yield* DesktopLifecycle.DesktopShutdown;
229 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
230
231 yield* Effect.addFinalizer(() =>
232 backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)),
233 );
234
235 yield* startup;
236 yield* shutdown.awaitRequest;
237 }),
238);
239
240export const program = scopedProgram.pipe(Effect.withSpan("desktop.app"));
apps/desktop/src/app/DesktopAppIdentity.test.ts created+176
...@@ -0,0 +1,176 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7
8import type * as Electron from "electron";
9
10import * as ElectronApp from "../electron/ElectronApp.ts";
11import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
12import * as DesktopAssets from "./DesktopAssets.ts";
13import * as DesktopConfig from "./DesktopConfig.ts";
14import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15
16const defaultEnvironmentInput = {
17 dirname: "/repo/apps/desktop/dist-electron",
18 homeDirectory: "/Users/alice",
19 platform: "darwin",
20 processArch: "arm64",
21 appVersion: "1.2.3",
22 appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
23 isPackaged: true,
24 resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
25 runningUnderArm64Translation: false,
26} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
27
28type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> & {
29 readonly env?: Record<string, string | undefined>;
30};
31
32interface ElectronAppCalls {
33 readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>;
34 readonly setDockIcon: string[];
35 readonly setName: string[];
36}
37
38const makeElectronAppLayer = (calls: ElectronAppCalls) =>
39 Layer.succeed(ElectronApp.ElectronApp, {
40 metadata: Effect.die("unexpected metadata read"),
41 name: Effect.succeed("T3 Code"),
42 whenReady: Effect.void,
43 quit: Effect.void,
44 exit: () => Effect.void,
45 relaunch: () => Effect.void,
46 setPath: () => Effect.void,
47 setName: (name) =>
48 Effect.sync(() => {
49 calls.setName.push(name);
50 }),
51 setAboutPanelOptions: (options) =>
52 Effect.sync(() => {
53 calls.setAboutPanelOptions.push(options);
54 }),
55 setAppUserModelId: () => Effect.void,
56 setDesktopName: () => Effect.void,
57 setDockIcon: (iconPath) =>
58 Effect.sync(() => {
59 calls.setDockIcon.push(iconPath);
60 }),
61 appendCommandLineSwitch: () => Effect.void,
62 on: () => Effect.void,
63 } satisfies ElectronApp.ElectronAppShape);
64
65const makeAssetsLayer = (png: Option.Option<string>) =>
66 Layer.succeed(DesktopAssets.DesktopAssets, {
67 iconPaths: Effect.succeed({
68 ico: Option.none(),
69 icns: Option.none(),
70 png,
71 }),
72 resolveResourcePath: () => Effect.succeed(Option.none()),
73 } satisfies DesktopAssets.DesktopAssetsShape);
74
75const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => {
76 const { env, ...environmentOverrides } = overrides;
77 return DesktopEnvironment.layer({
78 ...defaultEnvironmentInput,
79 ...environmentOverrides,
80 }).pipe(
81 Layer.provide(
82 Layer.mergeAll(
83 NodeServices.layer,
84 DesktopConfig.layerTest({
85 ...env,
86 }),
87 ),
88 ),
89 );
90};
91
92const withIdentity = <A, E, R>(
93 effect: Effect.Effect<
94 A,
95 E,
96 | R
97 | DesktopAppIdentity.DesktopAppIdentity
98 | DesktopEnvironment.DesktopEnvironment
99 | FileSystem.FileSystem
100 >,
101 input: {
102 readonly calls?: ElectronAppCalls;
103 readonly environment?: TestEnvironmentInput;
104 readonly legacyPathExists?: boolean;
105 readonly packageJson?: string;
106 readonly pngIconPath?: Option.Option<string>;
107 } = {},
108) => {
109 const calls: ElectronAppCalls = input.calls ?? {
110 setAboutPanelOptions: [],
111 setDockIcon: [],
112 setName: [],
113 };
114
115 return effect.pipe(
116 Effect.provide(
117 DesktopAppIdentity.layer.pipe(
118 Layer.provideMerge(
119 FileSystem.layerNoop({
120 exists: (path) =>
121 Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")),
122 readFileString: () =>
123 Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'),
124 }),
125 ),
126 Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())),
127 Layer.provideMerge(makeElectronAppLayer(calls)),
128 Layer.provideMerge(makeEnvironmentLayer(input.environment)),
129 ),
130 ),
131 );
132};
133
134describe("DesktopAppIdentity", () => {
135 it.effect("keeps using the legacy userData path when it already exists", () =>
136 withIdentity(
137 Effect.gen(function* () {
138 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
139 const userDataPath = yield* identity.resolveUserDataPath;
140
141 assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)");
142 }),
143 { legacyPathExists: true },
144 ),
145 );
146
147 it.effect("configures app identity from the environment commit override", () => {
148 const calls: ElectronAppCalls = {
149 setAboutPanelOptions: [],
150 setDockIcon: [],
151 setName: [],
152 };
153
154 return withIdentity(
155 Effect.gen(function* () {
156 const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
157 yield* identity.configure;
158
159 assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]);
160 assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)");
161 assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
162 assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
163 assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
164 }),
165 {
166 calls,
167 environment: {
168 env: {
169 T3CODE_COMMIT_HASH: "0123456789abcdef",
170 },
171 },
172 pngIconPath: Option.some("/icon.png"),
173 },
174 );
175 });
176});
apps/desktop/src/app/DesktopAppIdentity.ts created+127
...@@ -0,0 +1,127 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8
9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as DesktopAssets from "./DesktopAssets.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
14const COMMIT_HASH_DISPLAY_LENGTH = 12;
15
16const AppPackageMetadata = Schema.Struct({
17 t3codeCommitHash: Schema.optional(Schema.String),
18});
19
20export interface DesktopAppIdentityShape {
21 readonly resolveUserDataPath: Effect.Effect<string>;
22 readonly configure: Effect.Effect<void>;
23}
24
25export class DesktopAppIdentity extends Context.Service<
26 DesktopAppIdentity,
27 DesktopAppIdentityShape
28>()("t3/desktop/AppIdentity") {}
29
30const normalizeCommitHash = (value: string): Option.Option<string> => {
31 const trimmed = value.trim();
32 return COMMIT_HASH_PATTERN.test(trimmed)
33 ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase())
34 : Option.none();
35};
36
37const make = Effect.gen(function* () {
38 const assets = yield* DesktopAssets.DesktopAssets;
39 const electronApp = yield* ElectronApp.ElectronApp;
40 const environment = yield* DesktopEnvironment.DesktopEnvironment;
41 const fileSystem = yield* FileSystem.FileSystem;
42 const commitHashCache = yield* Ref.make<Option.Option<Option.Option<string>>>(Option.none());
43
44 const resolveEmbeddedCommitHash = Effect.gen(function* () {
45 const packageJsonPath = environment.path.join(environment.appRoot, "package.json");
46 const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option);
47 return yield* Option.match(raw, {
48 onNone: () => Effect.succeed(Option.none<string>()),
49 onSome: (value) =>
50 Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe(
51 Effect.map((parsed) =>
52 Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)),
53 ),
54 Effect.catch(() => Effect.succeed(Option.none<string>())),
55 ),
56 });
57 });
58
59 const resolveAboutCommitHash = Effect.gen(function* () {
60 const cached = yield* Ref.get(commitHashCache);
61 if (Option.isSome(cached)) {
62 return cached.value;
63 }
64
65 const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash);
66 if (Option.isSome(override)) {
67 yield* Ref.set(commitHashCache, Option.some(override));
68 return override;
69 }
70
71 if (!environment.isPackaged) {
72 const empty = Option.none<string>();
73 yield* Ref.set(commitHashCache, Option.some(empty));
74 return empty;
75 }
76
77 const commitHash = yield* resolveEmbeddedCommitHash;
78 yield* Ref.set(commitHashCache, Option.some(commitHash));
79 return commitHash;
80 });
81
82 const resolveUserDataPath = Effect.gen(function* () {
83 const legacyPath = environment.path.join(
84 environment.appDataDirectory,
85 environment.legacyUserDataDirName,
86 );
87 const legacyPathExists = yield* fileSystem
88 .exists(legacyPath)
89 .pipe(Effect.orElseSucceed(() => false));
90 return legacyPathExists
91 ? legacyPath
92 : environment.path.join(environment.appDataDirectory, environment.userDataDirName);
93 }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));
94
95 const configure = Effect.gen(function* () {
96 const commitHash = yield* resolveAboutCommitHash;
97 yield* electronApp.setName(environment.displayName);
98 yield* electronApp.setAboutPanelOptions({
99 applicationName: environment.displayName,
100 applicationVersion: environment.appVersion,
101 version: Option.getOrElse(commitHash, () => "unknown"),
102 });
103
104 if (environment.platform === "win32") {
105 yield* electronApp.setAppUserModelId(environment.appUserModelId);
106 }
107
108 if (environment.platform === "linux") {
109 yield* electronApp.setDesktopName(environment.linuxDesktopEntryName);
110 }
111
112 if (environment.platform === "darwin") {
113 const iconPaths = yield* assets.iconPaths;
114 yield* Option.match(iconPaths.png, {
115 onNone: () => Effect.void,
116 onSome: electronApp.setDockIcon,
117 });
118 }
119 }).pipe(Effect.withSpan("desktop.appIdentity.configure"));
120
121 return DesktopAppIdentity.of({
122 resolveUserDataPath,
123 configure,
124 });
125});
126
127export const layer = Layer.effect(DesktopAppIdentity, make);
apps/desktop/src/app/DesktopAssets.ts created+85
...@@ -0,0 +1,85 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as DesktopEnvironment from "./DesktopEnvironment.ts";
8
9export interface DesktopIconPaths {
10 readonly ico: Option.Option<string>;
11 readonly icns: Option.Option<string>;
12 readonly png: Option.Option<string>;
13}
14
15export interface DesktopAssetsShape {
16 readonly iconPaths: Effect.Effect<DesktopIconPaths>;
17 readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>;
18}
19
20export class DesktopAssets extends Context.Service<DesktopAssets, DesktopAssetsShape>()(
21 "t3/desktop/Assets",
22) {}
23
24const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* (
25 fileName: string,
26): Effect.fn.Return<
27 Option.Option<string>,
28 never,
29 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
30> {
31 const fileSystem = yield* FileSystem.FileSystem;
32 const environment = yield* DesktopEnvironment.DesktopEnvironment;
33 const candidates = environment.resolveResourcePathCandidates(fileName);
34 for (const candidate of candidates) {
35 const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
36 if (exists) {
37 return Option.some(candidate);
38 }
39 }
40 return Option.none<string>();
41});
42
43const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
44 ext: keyof DesktopIconPaths,
45): Effect.fn.Return<
46 Option.Option<string>,
47 never,
48 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
49> {
50 const fileSystem = yield* FileSystem.FileSystem;
51 const environment = yield* DesktopEnvironment.DesktopEnvironment;
52 if (environment.isDevelopment && process.platform === "darwin" && ext === "png") {
53 const developmentDockIconPath = environment.developmentDockIconPath;
54 const developmentDockIconExists = yield* fileSystem
55 .exists(developmentDockIconPath)
56 .pipe(Effect.orElseSucceed(() => false));
57 if (developmentDockIconExists) {
58 return Option.some(developmentDockIconPath);
59 }
60 }
61
62 return yield* resolveResourcePath(`icon.${ext}`);
63});
64
65const make = Effect.gen(function* () {
66 const context = yield* Effect.context<
67 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
68 >();
69 const [ico, icns, png] = yield* Effect.all(
70 [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const,
71 { concurrency: "unbounded" },
72 );
73 const iconPaths = { ico, icns, png } satisfies DesktopIconPaths;
74
75 return DesktopAssets.of({
76 iconPaths: Effect.succeed(iconPaths),
77 resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")(
78 function* (fileName) {
79 return yield* resolveResourcePath(fileName).pipe(Effect.provide(context));
80 },
81 ),
82 });
83});
84
85export const layer = Layer.effect(DesktopAssets, make);
apps/desktop/src/app/DesktopConfig.ts created+58
...@@ -0,0 +1,58 @@
1import * as Config from "effect/Config";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Option from "effect/Option";
4
5const trimNonEmptyOption = (value: string): Option.Option<string> => {
6 const trimmed = value.trim();
7 return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
8};
9
10const trimmedString = (name: string) =>
11 Config.string(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption)));
12
13const optionalBoolean = (name: string) =>
14 Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false)));
15
16const commaSeparatedStrings = (name: string) =>
17 trimmedString(name).pipe(
18 Config.map(
19 Option.match({
20 onNone: () => [],
21 onSome: (value) =>
22 value
23 .split(",")
24 .map((entry) => entry.trim())
25 .filter((entry) => entry.length > 0),
26 }),
27 ),
28 );
29
30const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<string, string> =>
31 Object.fromEntries(
32 Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
33 );
34
35export const DesktopConfig = Config.all({
36 appDataDirectory: trimmedString("APPDATA"),
37 xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
38 t3Home: trimmedString("T3CODE_HOME"),
39 devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
40 devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),
41 configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option),
42 commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"),
43 desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"),
44 desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"),
45 otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"),
46 otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
47 Config.withDefault(10_000),
48 ),
49 appImagePath: trimmedString("APPIMAGE"),
50 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
51 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
52 mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(
53 Config.withDefault(3000),
54 ),
55});
56
57export const layerTest = (env: Readonly<Record<string, string | undefined>>) =>
58 ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) }));
apps/desktop/src/app/DesktopEnvironment.test.ts created+117
...@@ -0,0 +1,117 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as DesktopEnvironment from "./DesktopEnvironment.ts";
8import * as DesktopConfig from "./DesktopConfig.ts";
9
10const defaultInput = {
11 dirname: "/repo/apps/desktop/dist-electron",
12 homeDirectory: "/Users/alice",
13 platform: "darwin",
14 processArch: "arm64",
15 appVersion: "0.0.22",
16 appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
17 isPackaged: false,
18 resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
19 runningUnderArm64Translation: false,
20} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
21
22const makeEnvironmentLayer = (
23 overrides: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
24 env: Record<string, string | undefined> = {},
25) =>
26 DesktopEnvironment.layer({
27 ...defaultInput,
28 ...overrides,
29 }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env))));
30
31const makeEnvironment = (
32 overrides: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
33 env: Record<string, string | undefined> = {},
34) =>
35 Effect.gen(function* () {
36 return yield* DesktopEnvironment.DesktopEnvironment;
37 }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env)));
38
39describe("DesktopEnvironment", () => {
40 it.effect("derives state paths and development identity inside Effect", () =>
41 Effect.gen(function* () {
42 const environment = yield* makeEnvironment(
43 {},
44 {
45 T3CODE_HOME: " /tmp/t3 ",
46 T3CODE_COMMIT_HASH: " 0123456789abcdef ",
47 T3CODE_PORT: "4949",
48 VITE_DEV_SERVER_URL: "http://localhost:5173",
49 T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
50 T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
51 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
52 },
53 );
54
55 assert.equal(environment.isDevelopment, true);
56 assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support");
57 assert.equal(environment.baseDir, "/tmp/t3");
58 assert.equal(environment.stateDir, "/tmp/t3/dev");
59 assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json");
60 assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json");
61 assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");
62 assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");
63 assert.equal(environment.logDir, "/tmp/t3/dev/logs");
64 assert.equal(environment.rootDir, "/repo");
65 assert.equal(environment.appRoot, "/repo");
66 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
67 assert.equal(environment.backendCwd, "/repo");
68 assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev");
69 assert.equal(environment.linuxWmClass, "t3code-dev");
70 assert.deepEqual(
71 Option.map(environment.devServerUrl, (url) => url.href),
72 Option.some("http://localhost:5173/"),
73 );
74 assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs"));
75 assert.deepEqual(environment.configuredBackendPort, Option.some(4949));
76 assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
77 assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));
78 assert.equal(environment.otlpExportIntervalMs, 2500);
79 }),
80 );
81
82 it.effect("derives production state paths under userdata", () =>
83 Effect.gen(function* () {
84 const environment = yield* makeEnvironment(
85 {},
86 {
87 T3CODE_HOME: "/tmp/t3",
88 },
89 );
90
91 assert.equal(environment.isDevelopment, false);
92 assert.equal(environment.stateDir, "/tmp/t3/userdata");
93 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
94 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
95 }),
96 );
97
98 it.effect("resolves picker defaults without nullish sentinels", () =>
99 Effect.gen(function* () {
100 const environment = yield* makeEnvironment();
101
102 assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none());
103 assert.deepEqual(
104 environment.resolvePickFolderDefaultPath({ initialPath: " " }),
105 Option.none(),
106 );
107 assert.deepEqual(
108 environment.resolvePickFolderDefaultPath({ initialPath: "~" }),
109 Option.some("/Users/alice"),
110 );
111 assert.deepEqual(
112 environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }),
113 Option.some("/Users/alice/project"),
114 );
115 }),
116 );
117});
apps/desktop/src/app/DesktopEnvironment.ts created+249
...@@ -0,0 +1,249 @@
1import type {
2 DesktopAppBranding,
3 DesktopAppStageLabel,
4 DesktopRuntimeArch,
5 DesktopRuntimeInfo,
6} from "@t3tools/contracts";
7import * as Config from "effect/Config";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13
14import {
15 type DesktopSettings,
16 resolveDefaultDesktopSettings,
17} from "../settings/DesktopAppSettings.ts";
18import * as DesktopConfig from "./DesktopConfig.ts";
19import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
20
21export interface MakeDesktopEnvironmentInput {
22 readonly dirname: string;
23 readonly homeDirectory: string;
24 readonly platform: NodeJS.Platform;
25 readonly processArch: string;
26 readonly appVersion: string;
27 readonly appPath: string;
28 readonly isPackaged: boolean;
29 readonly resourcesPath: string;
30 readonly runningUnderArm64Translation: boolean;
31}
32
33export interface DesktopEnvironmentShape {
34 readonly path: Path.Path;
35 readonly dirname: string;
36 readonly platform: NodeJS.Platform;
37 readonly processArch: string;
38 readonly isPackaged: boolean;
39 readonly isDevelopment: boolean;
40 readonly appVersion: string;
41 readonly appPath: string;
42 readonly resourcesPath: string;
43 readonly homeDirectory: string;
44 readonly appDataDirectory: string;
45 readonly baseDir: string;
46 readonly stateDir: string;
47 readonly desktopSettingsPath: string;
48 readonly clientSettingsPath: string;
49 readonly savedEnvironmentRegistryPath: string;
50 readonly serverSettingsPath: string;
51 readonly logDir: string;
52 readonly rootDir: string;
53 readonly appRoot: string;
54 readonly backendEntryPath: string;
55 readonly backendCwd: string;
56 readonly preloadPath: string;
57 readonly appUpdateYmlPath: string;
58 readonly devServerUrl: Option.Option<URL>;
59 readonly devRemoteT3ServerEntryPath: Option.Option<string>;
60 readonly configuredBackendPort: Option.Option<number>;
61 readonly commitHashOverride: Option.Option<string>;
62 readonly otlpTracesUrl: Option.Option<string>;
63 readonly otlpExportIntervalMs: number;
64 readonly branding: DesktopAppBranding;
65 readonly displayName: string;
66 readonly appUserModelId: string;
67 readonly linuxDesktopEntryName: string;
68 readonly linuxWmClass: string;
69 readonly userDataDirName: string;
70 readonly legacyUserDataDirName: string;
71 readonly defaultDesktopSettings: DesktopSettings;
72 readonly runtimeInfo: DesktopRuntimeInfo;
73 readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
74 readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
75 readonly developmentDockIconPath: string;
76}
77
78export class DesktopEnvironment extends Context.Service<
79 DesktopEnvironment,
80 DesktopEnvironmentShape
81>()("t3/desktop/Environment") {}
82
83const APP_BASE_NAME = "T3 Code";
84
85function resolveDesktopAppStageLabel(input: {
86 readonly isDevelopment: boolean;
87 readonly appVersion: string;
88}): DesktopAppStageLabel {
89 if (input.isDevelopment) {
90 return "Dev";
91 }
92
93 return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
94}
95
96function resolveDesktopAppBranding(input: {
97 readonly isDevelopment: boolean;
98 readonly appVersion: string;
99}): DesktopAppBranding {
100 const stageLabel = resolveDesktopAppStageLabel(input);
101 return {
102 baseName: APP_BASE_NAME,
103 stageLabel,
104 displayName: `${APP_BASE_NAME} (${stageLabel})`,
105 };
106}
107
108function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
109 if (arch === "arm64") return "arm64";
110 if (arch === "x64") return "x64";
111 return "other";
112}
113
114function resolveDesktopRuntimeInfo(input: {
115 readonly platform: NodeJS.Platform;
116 readonly processArch: string;
117 readonly runningUnderArm64Translation: boolean;
118}): DesktopRuntimeInfo {
119 const appArch = normalizeDesktopArch(input.processArch);
120
121 if (input.platform !== "darwin") {
122 return {
123 hostArch: appArch,
124 appArch,
125 runningUnderArm64Translation: false,
126 };
127 }
128
129 const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
130
131 return {
132 hostArch,
133 appArch,
134 runningUnderArm64Translation: input.runningUnderArm64Translation,
135 };
136}
137
138const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
139 input: MakeDesktopEnvironmentInput,
140): Effect.fn.Return<DesktopEnvironmentShape, Config.ConfigError, Path.Path> {
141 const path = yield* Path.Path;
142 const config = yield* DesktopConfig.DesktopConfig;
143 const homeDirectory = input.homeDirectory;
144 const devServerUrl = config.devServerUrl;
145 const isDevelopment = Option.isSome(devServerUrl);
146 const appDataDirectory =
147 input.platform === "win32"
148 ? Option.getOrElse(config.appDataDirectory, () =>
149 path.join(homeDirectory, "AppData", "Roaming"),
150 )
151 : input.platform === "darwin"
152 ? path.join(homeDirectory, "Library", "Application Support")
153 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
154 const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3"));
155 const rootDir = path.resolve(input.dirname, "../../..");
156 const appRoot = input.isPackaged ? input.appPath : rootDir;
157 const branding = resolveDesktopAppBranding({
158 isDevelopment,
159 appVersion: input.appVersion,
160 });
161 const displayName = branding.displayName;
162 const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata");
163 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
164 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
165 const resourcesPath = input.resourcesPath;
166
167 return DesktopEnvironment.of({
168 path,
169 dirname: input.dirname,
170 platform: input.platform,
171 processArch: input.processArch,
172 isPackaged: input.isPackaged,
173 isDevelopment,
174 appVersion: input.appVersion,
175 appPath: input.appPath,
176 resourcesPath,
177 homeDirectory,
178 appDataDirectory,
179 baseDir,
180 stateDir,
181 desktopSettingsPath: path.join(stateDir, "desktop-settings.json"),
182 clientSettingsPath: path.join(stateDir, "client-settings.json"),
183 savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
184 serverSettingsPath: path.join(stateDir, "settings.json"),
185 logDir: path.join(stateDir, "logs"),
186 rootDir,
187 appRoot,
188 backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),
189 backendCwd: input.isPackaged ? homeDirectory : appRoot,
190 preloadPath: path.join(input.dirname, "preload.cjs"),
191 appUpdateYmlPath: input.isPackaged
192 ? path.join(resourcesPath, "app-update.yml")
193 : path.join(input.appPath, "dev-app-update.yml"),
194 devServerUrl,
195 devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath,
196 configuredBackendPort: config.configuredBackendPort,
197 commitHashOverride: config.commitHashOverride,
198 otlpTracesUrl: config.otlpTracesUrl,
199 otlpExportIntervalMs: config.otlpExportIntervalMs,
200 branding,
201 displayName,
202 appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
203 linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
204 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
205 userDataDirName,
206 legacyUserDataDirName,
207 defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion),
208 runtimeInfo: resolveDesktopRuntimeInfo({
209 platform: input.platform,
210 processArch: input.processArch,
211 runningUnderArm64Translation: input.runningUnderArm64Translation,
212 }),
213 resolvePickFolderDefaultPath: (rawOptions) => {
214 if (typeof rawOptions !== "object" || rawOptions === null) {
215 return Option.none();
216 }
217
218 const { initialPath } = rawOptions as { initialPath?: unknown };
219 if (typeof initialPath !== "string") {
220 return Option.none();
221 }
222
223 const trimmedPath = initialPath.trim();
224 if (trimmedPath.length === 0) {
225 return Option.none();
226 }
227
228 if (trimmedPath === "~") {
229 return Option.some(homeDirectory);
230 }
231
232 if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
233 return Option.some(path.join(homeDirectory, trimmedPath.slice(2)));
234 }
235
236 return Option.some(path.resolve(trimmedPath));
237 },
238 resolveResourcePathCandidates: (fileName) => [
239 path.join(input.dirname, "../resources", fileName),
240 path.join(input.dirname, "../prod-resources", fileName),
241 path.join(resourcesPath, "resources", fileName),
242 path.join(resourcesPath, fileName),
243 ],
244 developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"),
245 });
246});
247
248export const layer = (input: MakeDesktopEnvironmentInput) =>
249 Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input));
apps/desktop/src/app/DesktopLifecycle.ts created+233
...@@ -0,0 +1,233 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import * as Scope from "effect/Scope";
8
9import type * as Electron from "electron";
10
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12import * as DesktopObservability from "./DesktopObservability.ts";
13import * as ElectronApp from "../electron/ElectronApp.ts";
14import * as ElectronTheme from "../electron/ElectronTheme.ts";
15import * as DesktopState from "./DesktopState.ts";
16import * as DesktopWindow from "../window/DesktopWindow.ts";
17
18export interface DesktopShutdownShape {
19 readonly request: Effect.Effect<void>;
20 readonly awaitRequest: Effect.Effect<void>;
21 readonly markComplete: Effect.Effect<void>;
22 readonly awaitComplete: Effect.Effect<void>;
23 readonly isComplete: Effect.Effect<boolean>;
24}
25
26export class DesktopShutdown extends Context.Service<DesktopShutdown, DesktopShutdownShape>()(
27 "t3/desktop/Shutdown",
28) {}
29
30const makeShutdown = Effect.gen(function* () {
31 const requested = yield* Deferred.make<void>();
32 const completed = yield* Deferred.make<void>();
33 const completedRef = yield* Ref.make(false);
34
35 return DesktopShutdown.of({
36 request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid),
37 awaitRequest: Deferred.await(requested),
38 markComplete: Ref.set(completedRef, true).pipe(
39 Effect.andThen(Deferred.succeed(completed, undefined)),
40 Effect.asVoid,
41 ),
42 awaitComplete: Deferred.await(completed),
43 isComplete: Ref.get(completedRef),
44 });
45});
46
47export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown);
48
49export type DesktopLifecycleRuntimeServices =
50 | DesktopEnvironment.DesktopEnvironment
51 | DesktopShutdown
52 | DesktopState.DesktopState
53 | DesktopWindow.DesktopWindow
54 | ElectronApp.ElectronApp
55 | ElectronTheme.ElectronTheme;
56
57export interface DesktopLifecycleShape {
58 readonly relaunch: (
59 reason: string,
60 ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
61 readonly register: Effect.Effect<void, never, Scope.Scope | DesktopLifecycleRuntimeServices>;
62}
63
64export class DesktopLifecycle extends Context.Service<DesktopLifecycle, DesktopLifecycleShape>()(
65 "t3/desktop/Lifecycle",
66) {}
67
68const { logInfo: logLifecycleInfo, logError: logLifecycleError } =
69 DesktopObservability.makeComponentLogger("desktop-lifecycle");
70
71function addScopedListener<Args extends ReadonlyArray<unknown>>(
72 target: unknown,
73 eventName: string,
74 listener: (...args: Args) => void,
75): Effect.Effect<void, never, Scope.Scope> {
76 const eventTarget = target as {
77 on: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
78 removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
79 };
80 const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
81 return Effect.acquireRelease(
82 Effect.sync(() => {
83 eventTarget.on(eventName, untypedListener);
84 }),
85 () =>
86 Effect.sync(() => {
87 eventTarget.removeListener(eventName, untypedListener);
88 }),
89 ).pipe(Effect.asVoid);
90}
91
92const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
93 function* (): Effect.fn.Return<void, never, DesktopShutdown> {
94 const shutdown = yield* DesktopShutdown;
95 yield* shutdown.request;
96 yield* shutdown.awaitComplete;
97 },
98);
99
100function handleBeforeQuit(
101 event: Electron.Event,
102 runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
103 allowQuit: () => boolean,
104 markQuitAllowed: () => void,
105): void {
106 if (allowQuit()) {
107 void runEffect(
108 Effect.gen(function* () {
109 const state = yield* DesktopState.DesktopState;
110 yield* Ref.set(state.quitting, true);
111 yield* logLifecycleInfo("before-quit received");
112 }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
113 );
114 return;
115 }
116
117 event.preventDefault();
118 void runEffect(
119 Effect.gen(function* () {
120 const state = yield* DesktopState.DesktopState;
121 yield* Ref.set(state.quitting, true);
122 yield* logLifecycleInfo("before-quit received");
123 yield* requestDesktopShutdownAndWait();
124 }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
125 ).finally(() => {
126 markQuitAllowed();
127 void runEffect(
128 Effect.gen(function* () {
129 const electronApp = yield* ElectronApp.ElectronApp;
130 yield* electronApp.quit;
131 }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
132 );
133 });
134}
135
136function quitFromSignal(
137 signal: "SIGINT" | "SIGTERM",
138 runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
139): void {
140 void runEffect(
141 Effect.gen(function* () {
142 yield* Effect.annotateCurrentSpan({ signal });
143 const electronApp = yield* ElectronApp.ElectronApp;
144 const state = yield* DesktopState.DesktopState;
145 const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
146 if (wasQuitting) return;
147 yield* logLifecycleInfo("process signal received", { signal });
148 yield* requestDesktopShutdownAndWait();
149 yield* electronApp.quit;
150 }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
151 );
152}
153
154export const layer = Layer.succeed(
155 DesktopLifecycle,
156 DesktopLifecycle.of({
157 relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
158 const electronApp = yield* ElectronApp.ElectronApp;
159 const environment = yield* DesktopEnvironment.DesktopEnvironment;
160 const state = yield* DesktopState.DesktopState;
161 yield* logLifecycleInfo("desktop relaunch requested", { reason });
162 yield* Effect.gen(function* () {
163 yield* Effect.yieldNow;
164 yield* Ref.set(state.quitting, true);
165 yield* requestDesktopShutdownAndWait();
166 if (environment.isDevelopment) {
167 yield* electronApp.exit(75);
168 return;
169 }
170 yield* electronApp.relaunch({
171 execPath: process.execPath,
172 args: process.argv.slice(1),
173 });
174 yield* electronApp.exit(0);
175 }).pipe(
176 Effect.catchCause((cause) =>
177 logLifecycleError("desktop relaunch failed", {
178 cause: Cause.pretty(cause),
179 }),
180 ),
181 Effect.forkDetach,
182 Effect.asVoid,
183 );
184 }),
185 register: Effect.gen(function* () {
186 const desktopWindow = yield* DesktopWindow.DesktopWindow;
187 const electronApp = yield* ElectronApp.ElectronApp;
188 const electronTheme = yield* ElectronTheme.ElectronTheme;
189 const environment = yield* DesktopEnvironment.DesktopEnvironment;
190 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
191 const runEffect = Effect.runPromiseWith(context);
192 let quitAllowed = false;
193 yield* electronTheme.onUpdated(() => {
194 void runEffect(
195 desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
196 );
197 });
198 yield* electronApp.on("before-quit", (event: Electron.Event) => {
199 handleBeforeQuit(
200 event,
201 runEffect,
202 () => quitAllowed,
203 () => {
204 quitAllowed = true;
205 },
206 );
207 });
208 yield* electronApp.on("activate", () => {
209 void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
210 });
211 yield* electronApp.on("window-all-closed", () => {
212 void runEffect(
213 Effect.gen(function* () {
214 const app = yield* ElectronApp.ElectronApp;
215 const state = yield* DesktopState.DesktopState;
216 if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
217 yield* app.quit;
218 }
219 }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
220 );
221 });
222
223 if (environment.platform !== "win32") {
224 yield* addScopedListener(process, "SIGINT", () => {
225 quitFromSignal("SIGINT", runEffect);
226 });
227 yield* addScopedListener(process, "SIGTERM", () => {
228 quitFromSignal("SIGTERM", runEffect);
229 });
230 }
231 }).pipe(Effect.withSpan("desktop.lifecycle.register")),
232 }),
233);
apps/desktop/src/app/DesktopObservability.test.ts created+162
...@@ -0,0 +1,162 @@
1import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8
9import * as DesktopConfig from "./DesktopConfig.ts";
10import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11import * as DesktopObservability from "./DesktopObservability.ts";
12
13const DesktopBackendChildLogRecord = Schema.Struct({
14 message: Schema.String,
15 level: Schema.Literals(["INFO", "ERROR"]),
16 timestamp: Schema.String,
17 annotations: Schema.Record(Schema.String, Schema.Unknown),
18 spans: Schema.Record(Schema.String, Schema.Unknown),
19 fiberId: Schema.String,
20});
21
22const decodeDesktopBackendChildLogRecord = Schema.decodeEffect(
23 Schema.fromJsonString(DesktopBackendChildLogRecord),
24);
25
26const TraceRecordLine = Schema.Struct({
27 name: Schema.String,
28 attributes: Schema.Record(Schema.String, Schema.Unknown),
29 events: Schema.Array(
30 Schema.Struct({
31 name: Schema.String,
32 attributes: Schema.Record(Schema.String, Schema.Unknown),
33 }),
34 ),
35});
36
37const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine));
38
39const environmentInput = (baseDir: string) =>
40 ({
41 dirname: "/repo/apps/desktop/dist-electron",
42 homeDirectory: baseDir,
43 platform: "darwin",
44 processArch: "arm64",
45 appVersion: "1.2.3",
46 appPath: "/repo",
47 isPackaged: false,
48 resourcesPath: "/repo/resources",
49 runningUnderArm64Translation: false,
50 }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
51
52const makeEnvironmentLayer = (baseDir: string) =>
53 DesktopEnvironment.layer(environmentInput(baseDir)).pipe(
54 Layer.provide(
55 Layer.mergeAll(
56 NodeServices.layer,
57 DesktopConfig.layerTest({
58 T3CODE_HOME: baseDir,
59 VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
60 }),
61 ),
62 ),
63 );
64
65describe("DesktopObservability", () => {
66 it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () =>
67 Effect.gen(function* () {
68 const fileSystem = yield* FileSystem.FileSystem;
69 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
70 prefix: "t3-desktop-observability-test-",
71 });
72 const environmentLayer = makeEnvironmentLayer(baseDir);
73 const tracePath = yield* Effect.gen(function* () {
74 const environment = yield* DesktopEnvironment.DesktopEnvironment;
75 return environment.path.join(environment.logDir, "desktop.trace.ndjson");
76 }).pipe(Effect.provide(environmentLayer));
77 const logPath = yield* Effect.gen(function* () {
78 const environment = yield* DesktopEnvironment.DesktopEnvironment;
79 return environment.path.join(environment.logDir, "desktop-main.log");
80 }).pipe(Effect.provide(environmentLayer));
81
82 yield* Effect.scoped(
83 Effect.gen(function* () {
84 yield* Effect.annotateCurrentSpan({ "desktop.test": true });
85 yield* Effect.logInfo("desktop trace event");
86 }).pipe(
87 Effect.withSpan("desktop-observability-test"),
88 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
89 ),
90 );
91
92 const records = (yield* fileSystem.readFileString(tracePath))
93 .trim()
94 .split("\n")
95 .filter((line) => line.length > 0)
96 .map((line) => decodeTraceRecordLine(line));
97 const record = records.find((entry) => entry.name === "desktop-observability-test");
98
99 assert.notEqual(record, undefined);
100 if (!record) {
101 return;
102 }
103 assert.equal(record.attributes["desktop.test"], true);
104 assert.equal(
105 record.events.some((event) => event.name === "desktop trace event"),
106 true,
107 );
108 assert.isFalse(yield* fileSystem.exists(logPath));
109 }).pipe(
110 Effect.scoped,
111 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
112 ),
113 );
114
115 it.effect("persists backend child output as structured JSON records in development", () =>
116 Effect.gen(function* () {
117 const fileSystem = yield* FileSystem.FileSystem;
118 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
119 prefix: "t3-desktop-backend-output-log-test-",
120 });
121 const environmentLayer = makeEnvironmentLayer(baseDir);
122 const logPath = yield* Effect.gen(function* () {
123 const environment = yield* DesktopEnvironment.DesktopEnvironment;
124 return environment.path.join(environment.logDir, "server-child.log");
125 }).pipe(Effect.provide(environmentLayer));
126
127 yield* Effect.gen(function* () {
128 const outputLog = yield* DesktopObservability.DesktopBackendOutputLog;
129 yield* outputLog.writeSessionBoundary({
130 phase: "START",
131 details: "pid=123 port=3773 cwd=/repo",
132 });
133 yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n"));
134 }).pipe(
135 Effect.annotateLogs({ runId: "test-run" }),
136 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
137 );
138
139 const log = yield* fileSystem.readFileString(logPath);
140 const lines = log.trimEnd().split("\n");
141 const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");
142 const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? "");
143
144 assert.equal(boundary.message, "backend child process session start");
145 assert.equal(boundary.level, "INFO");
146 assert.equal(boundary.annotations.component, "desktop-backend-child");
147 assert.equal(boundary.annotations.runId, "test-run");
148 assert.equal(boundary.annotations.phase, "START");
149 assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo");
150
151 assert.equal(output.message, "backend child process output");
152 assert.equal(output.level, "INFO");
153 assert.equal(output.annotations.component, "desktop-backend-child");
154 assert.equal(output.annotations.runId, "test-run");
155 assert.equal(output.annotations.stream, "stdout");
156 assert.equal(output.annotations.text, "hello server\n");
157 }).pipe(
158 Effect.scoped,
159 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
160 ),
161 );
162});
apps/desktop/src/app/DesktopObservability.ts created+395
...@@ -0,0 +1,395 @@
1import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as References from "effect/References";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as Tracer from "effect/Tracer";
18import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
19
20import * as DesktopEnvironment from "./DesktopEnvironment.ts";
21
22const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
23const DESKTOP_LOG_FILE_MAX_FILES = 10;
24const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
25const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;
26
27export interface RotatingLogFileWriter {
28 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
29 readonly writeText: (chunk: string) => Effect.Effect<void>;
30}
31
32export interface DesktopBackendOutputLogShape {
33 readonly writeSessionBoundary: (input: {
34 readonly phase: "START" | "END";
35 readonly details: string;
36 }) => Effect.Effect<void>;
37 readonly writeOutputChunk: (
38 streamName: "stdout" | "stderr",
39 chunk: Uint8Array,
40 ) => Effect.Effect<void>;
41}
42
43export class DesktopBackendOutputLog extends Context.Service<
44 DesktopBackendOutputLog,
45 DesktopBackendOutputLogShape
46>()("t3/desktop/BackendOutputLog") {}
47
48const textEncoder = new TextEncoder();
49const textDecoder = new TextDecoder();
50
51export type DesktopLogAnnotations = Record<string, unknown>;
52
53export interface DesktopComponentLogger {
54 readonly annotate: <A, E, R>(
55 effect: Effect.Effect<A, E, R>,
56 annotations?: DesktopLogAnnotations,
57 ) => Effect.Effect<A, E, R>;
58 readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;
59 readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;
60 readonly logWarning: (
61 message: string,
62 annotations?: DesktopLogAnnotations,
63 ) => Effect.Effect<void>;
64 readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;
65}
66
67export function makeComponentLogger(component: string): DesktopComponentLogger {
68 const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) =>
69 effect.pipe(
70 Effect.annotateLogs({
71 component,
72 ...annotations,
73 }),
74 );
75
76 return {
77 annotate,
78 logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations),
79 logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations),
80 logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations),
81 logError: (message, annotations) => annotate(Effect.logError(message), annotations),
82 };
83}
84
85class DesktopLogFileWriterConfigurationError extends Data.TaggedError(
86 "DesktopLogFileWriterConfigurationError",
87)<{
88 readonly option: "maxBytes" | "maxFiles";
89 readonly value: number;
90}> {
91 override get message() {
92 return `${this.option} must be >= 1 (received ${this.value})`;
93 }
94}
95
96type DesktopLogFileWriterError =
97 | DesktopLogFileWriterConfigurationError
98 | PlatformError.PlatformError;
99
100const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim();
101
102const DesktopBackendChildLogRecord = Schema.Struct({
103 message: Schema.String,
104 level: Schema.Literals(["INFO", "ERROR"]),
105 timestamp: Schema.String,
106 annotations: Schema.Record(Schema.String, Schema.Unknown),
107 spans: Schema.Record(Schema.String, Schema.Unknown),
108 fiberId: Schema.String,
109});
110
111const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
112 Schema.fromJsonString(DesktopBackendChildLogRecord),
113);
114
115const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {
116 writeSessionBoundary: () => Effect.void,
117 writeOutputChunk: () => Effect.void,
118};
119
120const currentDesktopRunId = Effect.gen(function* () {
121 const annotations = yield* References.CurrentLogAnnotations;
122 const runId = annotations.runId;
123 return typeof runId === "string" && runId.length > 0 ? runId : "unknown";
124});
125
126const refreshFileSize = (
127 fileSystem: FileSystem.FileSystem,
128 filePath: string,
129): Effect.Effect<number, never> =>
130 fileSystem.stat(filePath).pipe(
131 Effect.map((stat) => Number(stat.size)),
132 Effect.orElseSucceed(() => 0),
133 );
134
135const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {
136 readonly filePath: string;
137 readonly maxBytes?: number;
138 readonly maxFiles?: number;
139}): Effect.fn.Return<
140 RotatingLogFileWriter,
141 DesktopLogFileWriterError,
142 FileSystem.FileSystem | Path.Path
143> {
144 const fileSystem = yield* FileSystem.FileSystem;
145 const path = yield* Path.Path;
146 const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES;
147 const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES;
148 const directory = path.dirname(input.filePath);
149 const baseName = path.basename(input.filePath);
150
151 if (maxBytes < 1) {
152 return yield* new DesktopLogFileWriterConfigurationError({
153 option: "maxBytes",
154 value: maxBytes,
155 });
156 }
157 if (maxFiles < 1) {
158 return yield* new DesktopLogFileWriterConfigurationError({
159 option: "maxFiles",
160 value: maxFiles,
161 });
162 }
163
164 yield* fileSystem.makeDirectory(directory, { recursive: true });
165
166 const withSuffix = (index: number) => `${input.filePath}.${index}`;
167 const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath));
168 const mutex = yield* Semaphore.make(1);
169
170 const pruneOverflowBackups = Effect.gen(function* () {
171 const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
172 for (const entry of entries) {
173 if (!entry.startsWith(`${baseName}.`)) continue;
174 const suffix = Number(entry.slice(baseName.length + 1));
175 if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
176 yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
177 }
178 });
179
180 const rotate = Effect.gen(function* () {
181 yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore);
182 for (let index = maxFiles - 1; index >= 1; index -= 1) {
183 const source = withSuffix(index);
184 const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false));
185 if (sourceExists) {
186 yield* fileSystem.rename(source, withSuffix(index + 1));
187 }
188 }
189 const currentExists = yield* fileSystem
190 .exists(input.filePath)
191 .pipe(Effect.orElseSucceed(() => false));
192 if (currentExists) {
193 yield* fileSystem.rename(input.filePath, withSuffix(1));
194 }
195 yield* Ref.set(currentSize, 0);
196 }).pipe(
197 Effect.catch(() =>
198 refreshFileSize(fileSystem, input.filePath).pipe(
199 Effect.flatMap((size) => Ref.set(currentSize, size)),
200 ),
201 ),
202 );
203
204 const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => {
205 if (chunk.byteLength === 0) return Effect.void;
206
207 return mutex.withPermits(1)(
208 Effect.gen(function* () {
209 const beforeSize = yield* Ref.get(currentSize);
210 if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) {
211 yield* rotate;
212 }
213
214 yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" });
215 const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength;
216 yield* Ref.set(currentSize, afterSize);
217
218 if (afterSize > maxBytes) {
219 yield* rotate;
220 }
221 }).pipe(
222 Effect.catch(() =>
223 refreshFileSize(fileSystem, input.filePath).pipe(
224 Effect.flatMap((size) => Ref.set(currentSize, size)),
225 ),
226 ),
227 ),
228 );
229 };
230
231 yield* pruneOverflowBackups;
232
233 return {
234 writeBytes,
235 writeText: (chunk) => writeBytes(textEncoder.encode(chunk)),
236 } satisfies RotatingLogFileWriter;
237});
238
239const readPersistedOtlpTracesUrl: Effect.Effect<
240 Option.Option<string>,
241 never,
242 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
243> = Effect.gen(function* () {
244 const fileSystem = yield* FileSystem.FileSystem;
245 const environment = yield* DesktopEnvironment.DesktopEnvironment;
246 const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
247 if (Option.isNone(raw)) {
248 return Option.none();
249 }
250
251 const parsed = parsePersistedServerObservabilitySettings(raw.value);
252 return Option.fromNullishOr(parsed.otlpTracesUrl);
253});
254
255const resolveOtlpTracesUrl = Effect.gen(function* () {
256 const environment = yield* DesktopEnvironment.DesktopEnvironment;
257 if (Option.isSome(environment.otlpTracesUrl)) {
258 return environment.otlpTracesUrl;
259 }
260 return yield* readPersistedOtlpTracesUrl;
261});
262
263const writeDevelopmentConsoleOutput = (
264 streamName: "stdout" | "stderr",
265 chunk: Uint8Array,
266): Effect.Effect<void> =>
267 Effect.sync(() => {
268 const output = streamName === "stderr" ? process.stderr : process.stdout;
269 output.write(chunk);
270 }).pipe(Effect.ignore);
271
272const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")(
273 function* (
274 logFile: RotatingLogFileWriter,
275 input: {
276 readonly message: string;
277 readonly level: "INFO" | "ERROR";
278 readonly annotations: Record<string, unknown>;
279 },
280 ): Effect.fn.Return<void> {
281 return yield* Effect.gen(function* () {
282 const timestamp = DateTime.formatIso(yield* DateTime.now);
283 const encoded = yield* encodeDesktopBackendChildLogRecord({
284 message: input.message,
285 level: input.level,
286 timestamp,
287 annotations: input.annotations,
288 spans: {},
289 fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID,
290 });
291 yield* logFile.writeText(`${encoded}\n`);
292 }).pipe(Effect.ignore({ log: true }));
293 },
294);
295
296const backendOutputLogLayer = Layer.effect(
297 DesktopBackendOutputLog,
298 Effect.gen(function* () {
299 const environment = yield* DesktopEnvironment.DesktopEnvironment;
300
301 const writer = yield* makeRotatingLogFileWriter({
302 filePath: environment.path.join(environment.logDir, "server-child.log"),
303 }).pipe(Effect.option);
304
305 return Option.match(writer, {
306 onNone: () => DesktopBackendOutputLogNoop,
307 onSome: (logFile) =>
308 ({
309 writeSessionBoundary: Effect.fn(
310 "desktop.observability.backendOutput.writeSessionBoundary",
311 )(function* ({ phase, details }) {
312 const runId = yield* currentDesktopRunId;
313 yield* writeBackendChildLogRecord(logFile, {
314 message: `backend child process session ${phase.toLowerCase()}`,
315 level: "INFO",
316 annotations: {
317 component: "desktop-backend-child",
318 runId,
319 phase,
320 details: sanitizeLogValue(details),
321 },
322 });
323 }),
324 writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")(
325 function* (streamName, chunk) {
326 if (environment.isDevelopment) {
327 yield* writeDevelopmentConsoleOutput(streamName, chunk);
328 }
329 const runId = yield* currentDesktopRunId;
330 yield* writeBackendChildLogRecord(logFile, {
331 message: "backend child process output",
332 level: streamName === "stderr" ? "ERROR" : "INFO",
333 annotations: {
334 component: "desktop-backend-child",
335 runId,
336 stream: streamName,
337 text: textDecoder.decode(chunk),
338 },
339 });
340 },
341 ),
342 }) satisfies DesktopBackendOutputLogShape,
343 });
344 }),
345);
346
347const desktopLoggerLayer = Layer.mergeAll(
348 Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),
349 Layer.succeed(References.MinimumLogLevel, "Info"),
350);
351
352const tracerLayer = Layer.unwrap(
353 Effect.gen(function* () {
354 const environment = yield* DesktopEnvironment.DesktopEnvironment;
355 const otlpTracesUrl = yield* resolveOtlpTracesUrl;
356 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");
357 const sink = yield* makeTraceSink({
358 filePath: tracePath,
359 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
360 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
361 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
362 });
363 const delegate = Option.isNone(otlpTracesUrl)
364 ? undefined
365 : yield* OtlpTracer.make({
366 url: otlpTracesUrl.value,
367 exportInterval: `${environment.otlpExportIntervalMs} millis`,
368 resource: {
369 serviceName: "desktop",
370 attributes: {
371 "service.runtime": "desktop",
372 "service.mode": environment.isDevelopment ? "development" : "packaged",
373 },
374 },
375 });
376 const tracer = yield* makeLocalFileTracer({
377 filePath: tracePath,
378 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
379 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
380 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
381 sink,
382 ...(delegate ? { delegate } : {}),
383 });
384
385 return Layer.succeed(Tracer.Tracer, tracer);
386 }),
387).pipe(Layer.provideMerge(OtlpSerialization.layerJson));
388
389export const layer = Layer.mergeAll(
390 backendOutputLogLayer,
391 desktopLoggerLayer,
392 tracerLayer,
393 Layer.succeed(Tracer.MinimumTraceLevel, "Info"),
394 Layer.succeed(References.TracerTimingEnabled, true),
395);
apps/desktop/src/app/DesktopState.ts created+21
...@@ -0,0 +1,21 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";
5
6export interface DesktopStateShape {
7 readonly backendReady: Ref.Ref<boolean>;
8 readonly quitting: Ref.Ref<boolean>;
9}
10
11export class DesktopState extends Context.Service<DesktopState, DesktopStateShape>()(
12 "t3/desktop/State",
13) {}
14
15export const layer = Layer.effect(
16 DesktopState,
17 Effect.all({
18 backendReady: Ref.make(false),
19 quitting: Ref.make(false),
20 }),
21);
apps/desktop/src/appBranding.test.ts deleted-47
...@@ -1,47 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts";
4
5describe("resolveDesktopAppStageLabel", () => {
6 it("uses Dev in desktop development", () => {
7 expect(
8 resolveDesktopAppStageLabel({
9 isDevelopment: true,
10 appVersion: "0.0.17-nightly.20260414.1",
11 }),
12 ).toBe("Dev");
13 });
14
15 it("uses Nightly for packaged nightly builds", () => {
16 expect(
17 resolveDesktopAppStageLabel({
18 isDevelopment: false,
19 appVersion: "0.0.17-nightly.20260414.1",
20 }),
21 ).toBe("Nightly");
22 });
23
24 it("uses Alpha for packaged stable builds", () => {
25 expect(
26 resolveDesktopAppStageLabel({
27 isDevelopment: false,
28 appVersion: "0.0.17",
29 }),
30 ).toBe("Alpha");
31 });
32});
33
34describe("resolveDesktopAppBranding", () => {
35 it("returns a complete desktop branding payload", () => {
36 expect(
37 resolveDesktopAppBranding({
38 isDevelopment: false,
39 appVersion: "0.0.17-nightly.20260414.1",
40 }),
41 ).toEqual({
42 baseName: "T3 Code",
43 stageLabel: "Nightly",
44 displayName: "T3 Code (Nightly)",
45 });
46 });
47});
apps/desktop/src/appBranding.ts deleted-28
...@@ -1,28 +0,0 @@
1import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
2
3import { isNightlyDesktopVersion } from "./updateChannels.ts";
4
5const APP_BASE_NAME = "T3 Code";
6
7export function resolveDesktopAppStageLabel(input: {
8 readonly isDevelopment: boolean;
9 readonly appVersion: string;
10}): DesktopAppStageLabel {
11 if (input.isDevelopment) {
12 return "Dev";
13 }
14
15 return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
16}
17
18export function resolveDesktopAppBranding(input: {
19 readonly isDevelopment: boolean;
20 readonly appVersion: string;
21}): DesktopAppBranding {
22 const stageLabel = resolveDesktopAppStageLabel(input);
23 return {
24 baseName: APP_BASE_NAME,
25 stageLabel,
26 displayName: `${APP_BASE_NAME} (${stageLabel})`,
27 };
28}
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts created+195
...@@ -0,0 +1,195 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7
8import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
10import * as DesktopConfig from "../app/DesktopConfig.ts";
11import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
12
13const PersistedServerObservabilitySettingsDocument = Schema.Struct({
14 observability: Schema.Struct({
15 otlpTracesUrl: Schema.String,
16 otlpMetricsUrl: Schema.String,
17 }),
18});
19
20const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect(
21 Schema.fromJsonString(PersistedServerObservabilitySettingsDocument),
22);
23
24const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {
25 getState: Effect.die("unexpected getState"),
26 backendConfig: Effect.succeed({
27 port: 4888,
28 bindHost: "0.0.0.0",
29 httpBaseUrl: new URL("http://127.0.0.1:4888"),
30 tailscaleServeEnabled: true,
31 tailscaleServePort: 8443,
32 }),
33 configureFromSettings: () => Effect.die("unexpected configureFromSettings"),
34 setMode: () => Effect.die("unexpected setMode"),
35 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
36 getAdvertisedEndpoints: Effect.succeed([]),
37} satisfies DesktopServerExposure.DesktopServerExposureShape);
38
39function makeEnvironmentLayer(
40 baseDir: string,
41 options?: {
42 readonly isPackaged?: boolean;
43 readonly devServerUrl?: string;
44 },
45) {
46 return DesktopEnvironment.layer({
47 dirname: "/repo/apps/desktop/src",
48 homeDirectory: baseDir,
49 platform: "darwin",
50 processArch: "x64",
51 appVersion: "1.2.3",
52 appPath: "/repo",
53 isPackaged: options?.isPackaged ?? true,
54 resourcesPath: "/missing/resources",
55 runningUnderArm64Translation: false,
56 }).pipe(
57 Layer.provide(
58 Layer.mergeAll(
59 NodeServices.layer,
60 DesktopConfig.layerTest({
61 T3CODE_HOME: baseDir,
62 T3CODE_PORT: "9999",
63 T3CODE_MODE: "desktop",
64 T3CODE_DESKTOP_LAN_HOST: "192.168.1.50",
65 VITE_DEV_SERVER_URL: options?.devServerUrl,
66 }),
67 ),
68 ),
69 );
70}
71
72const withHarness = <A, E, R>(
73 effect: Effect.Effect<
74 A,
75 E,
76 | R
77 | DesktopEnvironment.DesktopEnvironment
78 | FileSystem.FileSystem
79 | DesktopBackendConfiguration.DesktopBackendConfiguration
80 >,
81) =>
82 Effect.gen(function* () {
83 const fileSystem = yield* FileSystem.FileSystem;
84 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
85 prefix: "t3-desktop-backend-config-test-",
86 });
87
88 return yield* effect.pipe(
89 Effect.provide(
90 DesktopBackendConfiguration.layer.pipe(
91 Layer.provideMerge(serverExposureLayer),
92 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
93 ),
94 ),
95 );
96 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
97
98describe("DesktopBackendConfiguration", () => {
99 it.effect("resolves backend start config with a stable scoped bootstrap token", () =>
100 withHarness(
101 Effect.gen(function* () {
102 const environment = yield* DesktopEnvironment.DesktopEnvironment;
103 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
104
105 const first = yield* configuration.resolve;
106 const second = yield* configuration.resolve;
107
108 assert.equal(first.executablePath, process.execPath);
109 assert.equal(first.entryPath, environment.backendEntryPath);
110 assert.equal(first.cwd, environment.backendCwd);
111 assert.equal(first.captureOutput, true);
112 assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
113 assert.isUndefined(first.env.T3CODE_PORT);
114 assert.isUndefined(first.env.T3CODE_MODE);
115 assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
116
117 assert.equal(first.bootstrap.mode, "desktop");
118 assert.equal(first.bootstrap.noBrowser, true);
119 assert.equal(first.bootstrap.port, 4888);
120 assert.equal(first.bootstrap.host, "0.0.0.0");
121 assert.equal(first.bootstrap.t3Home, environment.baseDir);
122 assert.equal(first.bootstrap.tailscaleServeEnabled, true);
123 assert.equal(first.bootstrap.tailscaleServePort, 8443);
124 assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i);
125 assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken);
126 }),
127 ),
128 );
129
130 it.effect("includes persisted backend observability endpoints when present", () =>
131 withHarness(
132 Effect.gen(function* () {
133 const fileSystem = yield* FileSystem.FileSystem;
134 const environment = yield* DesktopEnvironment.DesktopEnvironment;
135 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
136
137 yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), {
138 recursive: true,
139 });
140 yield* fileSystem.writeFileString(
141 environment.serverSettingsPath,
142 yield* encodePersistedServerObservabilitySettingsDocument({
143 observability: {
144 otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ",
145 otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ",
146 },
147 }),
148 );
149
150 const config = yield* configuration.resolve;
151 assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
152 assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
153 }),
154 ),
155 );
156
157 it.effect("omits backend observability endpoints when settings are missing", () =>
158 withHarness(
159 Effect.gen(function* () {
160 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
161 const config = yield* configuration.resolve;
162
163 assert.isUndefined(config.bootstrap.otlpTracesUrl);
164 assert.isUndefined(config.bootstrap.otlpMetricsUrl);
165 }),
166 ),
167 );
168
169 it.effect("captures backend output in development so child process logs can be persisted", () =>
170 Effect.gen(function* () {
171 const fileSystem = yield* FileSystem.FileSystem;
172 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
173 prefix: "t3-desktop-backend-config-test-",
174 });
175
176 yield* Effect.gen(function* () {
177 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
178 const config = yield* configuration.resolve;
179 assert.equal(config.captureOutput, true);
180 }).pipe(
181 Effect.provide(
182 DesktopBackendConfiguration.layer.pipe(
183 Layer.provideMerge(serverExposureLayer),
184 Layer.provideMerge(
185 makeEnvironmentLayer(baseDir, {
186 isPackaged: false,
187 devServerUrl: "http://127.0.0.1:5733",
188 }),
189 ),
190 ),
191 ),
192 );
193 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
194 );
195});
apps/desktop/src/backend/DesktopBackendConfiguration.ts created+170
...@@ -0,0 +1,170 @@
1import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Random from "effect/Random";
8import * as Ref from "effect/Ref";
9
10import * as DesktopBackendManager from "./DesktopBackendManager.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as DesktopObservability from "../app/DesktopObservability.ts";
13import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
14
15export interface DesktopBackendConfigurationShape {
16 readonly resolve: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>;
17}
18
19export class DesktopBackendConfiguration extends Context.Service<
20 DesktopBackendConfiguration,
21 DesktopBackendConfigurationShape
22>()("t3/desktop/BackendConfiguration") {}
23
24interface BackendObservabilitySettings {
25 readonly otlpTracesUrl: Option.Option<string>;
26 readonly otlpMetricsUrl: Option.Option<string>;
27}
28
29const emptyBackendObservabilitySettings: BackendObservabilitySettings = {
30 otlpTracesUrl: Option.none(),
31 otlpMetricsUrl: Option.none(),
32};
33
34const DESKTOP_BACKEND_ENV_NAMES = [
35 "T3CODE_PORT",
36 "T3CODE_MODE",
37 "T3CODE_NO_BROWSER",
38 "T3CODE_HOST",
39 "T3CODE_DESKTOP_WS_URL",
40 "T3CODE_DESKTOP_LAN_ACCESS",
41 "T3CODE_DESKTOP_LAN_HOST",
42 "T3CODE_DESKTOP_HTTPS_ENDPOINTS",
43 "T3CODE_TAILSCALE_SERVE",
44 "T3CODE_TAILSCALE_SERVE_PORT",
45] as const;
46
47const backendChildEnvPatch = (): Record<string, string | undefined> =>
48 Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));
49
50const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger(
51 "desktop-backend-configuration",
52);
53
54const readPersistedBackendObservabilitySettings: Effect.Effect<
55 BackendObservabilitySettings,
56 never,
57 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
58> = Effect.gen(function* () {
59 const fileSystem = yield* FileSystem.FileSystem;
60 const environment = yield* DesktopEnvironment.DesktopEnvironment;
61 const exists = yield* fileSystem
62 .exists(environment.serverSettingsPath)
63 .pipe(Effect.orElseSucceed(() => false));
64 if (!exists) {
65 return emptyBackendObservabilitySettings;
66 }
67
68 const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
69 if (Option.isNone(raw)) {
70 yield* logBackendConfigurationWarning(
71 "failed to read persisted backend observability settings",
72 );
73 return emptyBackendObservabilitySettings;
74 }
75
76 const parsed = parsePersistedServerObservabilitySettings(raw.value);
77 return {
78 otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl),
79 otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl),
80 };
81});
82
83const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")(
84 function* (tokenRef: Ref.Ref<Option.Option<string>>) {
85 const existing = yield* Ref.get(tokenRef);
86 if (Option.isSome(existing)) {
87 return existing.value;
88 }
89
90 let token = "";
91 while (token.length < 48) {
92 token += (yield* Random.nextUUIDv4).replace(/-/g, "");
93 }
94 token = token.slice(0, 48);
95 yield* Ref.set(tokenRef, Option.some(token));
96 return token;
97 },
98);
99
100const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")(
101 function* (input: {
102 readonly bootstrapToken: string;
103 readonly observabilitySettings: BackendObservabilitySettings;
104 }): Effect.fn.Return<
105 DesktopBackendManager.DesktopBackendStartConfig,
106 never,
107 DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure
108 > {
109 const environment = yield* DesktopEnvironment.DesktopEnvironment;
110 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
111 const backendExposure = yield* serverExposure.backendConfig;
112
113 return {
114 executablePath: process.execPath,
115 entryPath: environment.backendEntryPath,
116 cwd: environment.backendCwd,
117 env: {
118 ...backendChildEnvPatch(),
119 ELECTRON_RUN_AS_NODE: "1",
120 },
121 bootstrap: {
122 mode: "desktop",
123 noBrowser: true,
124 port: backendExposure.port,
125 t3Home: environment.baseDir,
126 host: backendExposure.bindHost,
127 desktopBootstrapToken: input.bootstrapToken,
128 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
129 tailscaleServePort: backendExposure.tailscaleServePort,
130 ...Option.match(input.observabilitySettings.otlpTracesUrl, {
131 onNone: () => ({}),
132 onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),
133 }),
134 ...Option.match(input.observabilitySettings.otlpMetricsUrl, {
135 onNone: () => ({}),
136 onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),
137 }),
138 },
139 httpBaseUrl: backendExposure.httpBaseUrl,
140 captureOutput: true,
141 };
142 },
143);
144
145export const layer = Layer.effect(
146 DesktopBackendConfiguration,
147 Effect.gen(function* () {
148 const environment = yield* DesktopEnvironment.DesktopEnvironment;
149 const fileSystem = yield* FileSystem.FileSystem;
150 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
151 const tokenRef = yield* Ref.make(Option.none<string>());
152
153 return DesktopBackendConfiguration.of({
154 resolve: Effect.gen(function* () {
155 const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef);
156 const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
157 Effect.provideService(FileSystem.FileSystem, fileSystem),
158 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
159 );
160 return yield* resolveBackendStartConfig({
161 bootstrapToken,
162 observabilitySettings,
163 }).pipe(
164 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
165 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
166 );
167 }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")),
168 });
169 }),
170);
apps/desktop/src/backend/DesktopBackendManager.test.ts created+490
...@@ -0,0 +1,490 @@
1import {
2 DesktopBackendBootstrap,
3 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
4} from "@t3tools/contracts";
5import { assert, describe, it } from "@effect/vitest";
6import * as Deferred from "effect/Deferred";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import * as Sink from "effect/Sink";
16import * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18import { TestClock } from "effect/testing";
19import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
21
22import * as DesktopBackendManager from "./DesktopBackendManager.ts";
23import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
24import * as DesktopObservability from "../app/DesktopObservability.ts";
25import * as DesktopState from "../app/DesktopState.ts";
26import * as DesktopWindow from "../window/DesktopWindow.ts";
27
28const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
29 executablePath: "/electron",
30 entryPath: "/server/bin.mjs",
31 cwd: "/server",
32 env: { ELECTRON_RUN_AS_NODE: "1" },
33 bootstrap: {
34 mode: "desktop",
35 noBrowser: true,
36 port: 3773,
37 t3Home: "/tmp/t3",
38 host: "127.0.0.1",
39 desktopBootstrapToken: "token",
40 tailscaleServeEnabled: false,
41 tailscaleServePort: 443,
42 },
43 httpBaseUrl: new URL("http://127.0.0.1:3773"),
44 captureOutput: true,
45};
46
47const configWithObservability: DesktopBackendBootstrapValue = {
48 ...baseConfig.bootstrap,
49 tailscaleServeEnabled: true,
50 otlpTracesUrl: "http://127.0.0.1:4318/v1/traces",
51};
52
53function makeProcess(options?: {
54 readonly stdout?: Stream.Stream<Uint8Array>;
55 readonly stderr?: Stream.Stream<Uint8Array>;
56 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
57 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
58}): ChildProcessSpawner.ChildProcessHandle {
59 return ChildProcessSpawner.makeHandle({
60 pid: ChildProcessSpawner.ProcessId(123),
61 stdout: options?.stdout ?? Stream.empty,
62 stderr: options?.stderr ?? Stream.empty,
63 all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty),
64 exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)),
65 isRunning: Effect.succeed(false),
66 kill: options?.kill ?? (() => Effect.void),
67 stdin: Sink.drain,
68 getInputFd: () => Sink.drain,
69 getOutputFd: () => Stream.empty,
70 unref: Effect.succeed(Effect.void),
71 });
72}
73
74function responseForRequest(
75 request: HttpClientRequest.HttpClientRequest,
76 status: number,
77): HttpClientResponse.HttpClientResponse {
78 return HttpClientResponse.fromWeb(request, new Response(null, { status }));
79}
80
81function httpClientLayer(
82 handler: (
83 request: HttpClientRequest.HttpClientRequest,
84 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
85) {
86 return Layer.succeed(
87 HttpClient.HttpClient,
88 HttpClient.make((request) => handler(request)),
89 );
90}
91
92const healthyHttpClientLayer = httpClientLayer((request) =>
93 Effect.succeed(responseForRequest(request, 200)),
94);
95
96function decodeBootstrap(raw: string) {
97 return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw);
98}
99
100function makeManagerLayer(input: {
101 readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
102 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
103 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;
104 readonly desktopState?: DesktopState.DesktopStateShape;
105 readonly desktopWindow?: Partial<DesktopWindow.DesktopWindowShape>;
106 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
107}) {
108 return DesktopBackendManager.layer.pipe(
109 Layer.provide(
110 Layer.mergeAll(
111 FileSystem.layerNoop({
112 exists: () => Effect.succeed(true),
113 }),
114 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
115 resolve: Effect.succeed(input.config ?? baseConfig),
116 }),
117 input.spawnerLayer,
118 input.httpClientLayer ?? healthyHttpClientLayer,
119 input.desktopState
120 ? Layer.succeed(DesktopState.DesktopState, input.desktopState)
121 : DesktopState.layer,
122 Layer.succeed(DesktopObservability.DesktopBackendOutputLog, {
123 writeSessionBoundary: () => Effect.void,
124 writeOutputChunk: () => Effect.void,
125 ...input.backendOutputLog,
126 } satisfies DesktopObservability.DesktopBackendOutputLogShape),
127 Layer.succeed(DesktopWindow.DesktopWindow, {
128 createMain: Effect.die("unexpected createMain"),
129 ensureMain: Effect.die("unexpected ensureMain"),
130 revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
131 activate: Effect.void,
132 createMainIfBackendReady: Effect.void,
133 handleBackendReady: Effect.void,
134 dispatchMenuAction: () => Effect.void,
135 syncAppearance: Effect.void,
136 ...input.desktopWindow,
137 } satisfies DesktopWindow.DesktopWindowShape),
138 ),
139 ),
140 );
141}
142
143describe("DesktopBackendManager", () => {
144 it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>
145 Effect.gen(function* () {
146 let spawnedCommand: ChildProcess.Command | undefined;
147 let bootstrapJson = "";
148 let readyCount = 0;
149 const ready = yield* Deferred.make<void>();
150 const exited = yield* Queue.unbounded<void>();
151
152 const spawnerLayer = Layer.succeed(
153 ChildProcessSpawner.ChildProcessSpawner,
154 ChildProcessSpawner.make((command) =>
155 Effect.gen(function* () {
156 spawnedCommand = command;
157 if (command._tag === "StandardCommand") {
158 const fd3 = command.options.additionalFds?.fd3;
159 if (fd3?.type === "input" && fd3.stream) {
160 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
161 }
162 }
163
164 return makeProcess({
165 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
166 });
167 }),
168 ),
169 );
170
171 const managerLayer = makeManagerLayer({
172 config: {
173 ...baseConfig,
174 bootstrap: configWithObservability,
175 },
176 spawnerLayer,
177 desktopWindow: {
178 handleBackendReady: Effect.sync(() => {
179 readyCount += 1;
180 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))),
181 },
182 backendOutputLog: {
183 writeSessionBoundary: ({ phase }) =>
184 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
185 },
186 });
187
188 yield* Effect.gen(function* () {
189 const manager = yield* DesktopBackendManager.DesktopBackendManager;
190 yield* manager.start;
191 yield* Queue.take(exited);
192
193 assert.equal(readyCount, 1);
194 assert.isDefined(spawnedCommand);
195 if (spawnedCommand._tag !== "StandardCommand") {
196 throw new Error("Expected backend to spawn a standard command.");
197 }
198
199 assert.equal(spawnedCommand.command, "/electron");
200 assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]);
201 assert.equal(spawnedCommand.options.cwd, "/server");
202 assert.equal(spawnedCommand.options.extendEnv, true);
203 assert.equal(spawnedCommand.options.stdout, "pipe");
204 assert.equal(spawnedCommand.options.stderr, "pipe");
205 assert.equal(spawnedCommand.options.killSignal, "SIGTERM");
206 assert.isDefined(spawnedCommand.options.forceKillAfter);
207 assert.equal(
208 Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),
209 2_000,
210 );
211
212 assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);
213 }).pipe(Effect.provide(managerLayer));
214 }),
215 );
216
217 it.effect("retries HTTP readiness before reporting the backend ready", () =>
218 Effect.gen(function* () {
219 const requestUrls: Array<string> = [];
220 const statuses = [503, 200];
221 let readyCount = 0;
222 const firstRequest = yield* Deferred.make<void>();
223 const ready = yield* Deferred.make<void>();
224 const exited = yield* Queue.unbounded<void>();
225
226 const spawnerLayer = Layer.succeed(
227 ChildProcessSpawner.ChildProcessSpawner,
228 ChildProcessSpawner.make(() =>
229 Effect.succeed(
230 makeProcess({
231 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
232 }),
233 ),
234 ),
235 );
236
237 const managerLayer = makeManagerLayer({
238 spawnerLayer,
239 httpClientLayer: httpClientLayer((request) =>
240 Effect.gen(function* () {
241 const status = statuses.shift();
242 assert.isDefined(status);
243 requestUrls.push(request.url);
244 yield* Deferred.succeed(firstRequest, void 0);
245 return responseForRequest(request, status);
246 }),
247 ),
248 desktopWindow: {
249 handleBackendReady: Effect.sync(() => {
250 readyCount += 1;
251 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))),
252 },
253 backendOutputLog: {
254 writeSessionBoundary: ({ phase }) =>
255 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
256 },
257 });
258
259 yield* Effect.gen(function* () {
260 const manager = yield* DesktopBackendManager.DesktopBackendManager;
261 yield* manager.start;
262 yield* Deferred.await(firstRequest);
263
264 assert.equal(readyCount, 0);
265 assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);
266
267 yield* TestClock.adjust(Duration.millis(100));
268 yield* Queue.take(exited);
269
270 assert.equal(readyCount, 1);
271 assert.deepEqual(requestUrls, [
272 "http://127.0.0.1:3773/.well-known/t3/environment",
273 "http://127.0.0.1:3773/.well-known/t3/environment",
274 ]);
275 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
276 }),
277 );
278
279 it.effect("starts the configured backend and closes the scoped process on stop", () =>
280 Effect.gen(function* () {
281 let startCount = 0;
282 let closedCount = 0;
283 const closed = yield* Deferred.make<void>();
284 const startedPids = yield* Queue.unbounded<number>();
285 const ready = yield* Deferred.make<void>();
286 const backendReady = yield* Ref.make(false);
287 const quitting = yield* Ref.make(false);
288
289 const spawnerLayer = Layer.succeed(
290 ChildProcessSpawner.ChildProcessSpawner,
291 ChildProcessSpawner.make(() =>
292 Effect.gen(function* () {
293 const scope = yield* Scope.Scope;
294 startCount += 1;
295 yield* Queue.offer(startedPids, 123);
296 const close = Effect.sync(() => {
297 closedCount += 1;
298 }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid);
299
300 yield* Scope.addFinalizer(scope, close);
301
302 return makeProcess({
303 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
304 kill: () => close,
305 });
306 }),
307 ),
308 );
309
310 const managerLayer = makeManagerLayer({
311 spawnerLayer,
312 desktopState: {
313 backendReady,
314 quitting,
315 },
316 desktopWindow: {
317 handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid),
318 },
319 });
320
321 yield* Effect.gen(function* () {
322 const manager = yield* DesktopBackendManager.DesktopBackendManager;
323 assert.isTrue(Option.isNone(yield* manager.currentConfig));
324
325 yield* manager.start;
326 assert.equal(yield* Queue.take(startedPids), 123);
327 yield* Deferred.await(ready);
328 assert.isTrue(yield* Ref.get(backendReady));
329 assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig));
330
331 const runningSnapshot = yield* manager.snapshot;
332 assert.equal(runningSnapshot.ready, true);
333 assert.deepEqual(runningSnapshot.activePid, Option.some(123));
334
335 yield* manager.stop();
336 assert.equal(startCount, 1);
337 assert.equal(closedCount, 1);
338
339 const stoppedSnapshot = yield* manager.snapshot;
340 assert.isFalse(yield* Ref.get(backendReady));
341 assert.equal(stoppedSnapshot.desiredRunning, false);
342 assert.equal(stoppedSnapshot.ready, false);
343 assert.equal(Option.isNone(stoppedSnapshot.activePid), true);
344 }).pipe(Effect.provide(managerLayer));
345 }),
346 );
347
348 it.effect("restarts an unexpectedly exited backend with the Effect clock", () =>
349 Effect.gen(function* () {
350 const starts = yield* Queue.unbounded<number>();
351 let startCount = 0;
352
353 const spawnerLayer = Layer.succeed(
354 ChildProcessSpawner.ChildProcessSpawner,
355 ChildProcessSpawner.make(() =>
356 Effect.sync(() => {
357 startCount += 1;
358 return makeProcess({
359 exitCode: Queue.offer(starts, startCount).pipe(
360 Effect.as(ChildProcessSpawner.ExitCode(1)),
361 ),
362 });
363 }),
364 ),
365 );
366
367 const managerLayer = makeManagerLayer({
368 spawnerLayer,
369 httpClientLayer: httpClientLayer(() => Effect.never),
370 });
371
372 yield* Effect.gen(function* () {
373 const manager = yield* DesktopBackendManager.DesktopBackendManager;
374 yield* manager.start;
375
376 assert.equal(yield* Queue.take(starts), 1);
377
378 yield* TestClock.adjust(Duration.millis(499));
379 assert.equal(yield* Queue.size(starts), 0);
380 yield* TestClock.adjust(Duration.millis(1));
381 assert.equal(yield* Queue.take(starts), 2);
382
383 yield* TestClock.adjust(Duration.millis(999));
384 assert.equal(yield* Queue.size(starts), 0);
385 yield* TestClock.adjust(Duration.millis(1));
386 assert.equal(yield* Queue.take(starts), 3);
387 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
388 }),
389 );
390
391 it.effect("cancels a scheduled restart when start is requested manually", () =>
392 Effect.gen(function* () {
393 const starts = yield* Queue.unbounded<number>();
394 const secondClosed = yield* Deferred.make<void>();
395 let startCount = 0;
396
397 const spawnerLayer = Layer.succeed(
398 ChildProcessSpawner.ChildProcessSpawner,
399 ChildProcessSpawner.make(() =>
400 Effect.gen(function* () {
401 startCount += 1;
402 yield* Queue.offer(starts, startCount);
403
404 if (startCount === 1) {
405 return makeProcess({
406 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
407 });
408 }
409
410 const scope = yield* Scope.Scope;
411 const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid);
412 yield* Scope.addFinalizer(scope, close);
413 return makeProcess({
414 exitCode: Deferred.await(secondClosed).pipe(
415 Effect.as(ChildProcessSpawner.ExitCode(0)),
416 ),
417 kill: () => close,
418 });
419 }),
420 ),
421 );
422
423 const managerLayer = makeManagerLayer({
424 spawnerLayer,
425 httpClientLayer: httpClientLayer(() => Effect.never),
426 });
427
428 yield* Effect.gen(function* () {
429 const manager = yield* DesktopBackendManager.DesktopBackendManager;
430 yield* manager.start;
431
432 assert.equal(yield* Queue.take(starts), 1);
433
434 yield* manager.start;
435 assert.equal(yield* Queue.take(starts), 2);
436
437 yield* manager.stop();
438 yield* TestClock.adjust(Duration.millis(500));
439
440 assert.equal(yield* Queue.size(starts), 0);
441 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
442 }),
443 );
444
445 it.effect("does not restart after stop cancels a scheduled restart", () =>
446 Effect.gen(function* () {
447 const starts = yield* Queue.unbounded<number>();
448 let startCount = 0;
449
450 const spawnerLayer = Layer.succeed(
451 ChildProcessSpawner.ChildProcessSpawner,
452 ChildProcessSpawner.make(() =>
453 Effect.sync(() => {
454 startCount += 1;
455 return makeProcess({
456 exitCode: Queue.offer(starts, startCount).pipe(
457 Effect.as(ChildProcessSpawner.ExitCode(1)),
458 ),
459 });
460 }),
461 ),
462 );
463
464 const managerLayer = makeManagerLayer({
465 spawnerLayer,
466 httpClientLayer: httpClientLayer(() => Effect.never),
467 });
468
469 yield* Effect.gen(function* () {
470 const manager = yield* DesktopBackendManager.DesktopBackendManager;
471 yield* manager.start;
472 assert.equal(yield* Queue.take(starts), 1);
473
474 let restartScheduled = false;
475 while (!restartScheduled) {
476 restartScheduled = (yield* manager.snapshot).restartScheduled;
477 if (!restartScheduled) {
478 yield* Effect.yieldNow;
479 }
480 }
481
482 yield* manager.stop();
483 yield* TestClock.adjust(Duration.millis(500));
484
485 assert.equal(yield* Queue.size(starts), 0);
486 assert.equal((yield* manager.snapshot).desiredRunning, false);
487 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
488 }),
489 );
490});
apps/desktop/src/backend/DesktopBackendManager.ts created+596
...@@ -0,0 +1,596 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as PlatformError from "effect/PlatformError";
12import * as Ref from "effect/Ref";
13import * as Result from "effect/Result";
14import * as Schedule from "effect/Schedule";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as Scope from "effect/Scope";
18import * as Stream from "effect/Stream";
19import { HttpClient } from "effect/unstable/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
21
22import {
23 DesktopBackendBootstrap,
24 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
25} from "@t3tools/contracts";
26
27import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
28import * as DesktopObservability from "../app/DesktopObservability.ts";
29import * as DesktopState from "../app/DesktopState.ts";
30import * as DesktopWindow from "../window/DesktopWindow.ts";
31
32const INITIAL_RESTART_DELAY = Duration.millis(500);
33const MAX_RESTART_DELAY = Duration.seconds(10);
34const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
35const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
36const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
37const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);
38const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
39
40type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient;
41
42type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
43
44export type BackendProcessOutputStream = "stdout" | "stderr";
45
46export interface DesktopBackendStartConfig {
47 readonly executablePath: string;
48 readonly entryPath: string;
49 readonly cwd: string;
50 readonly env: Record<string, string | undefined>;
51 readonly bootstrap: DesktopBackendBootstrapValue;
52 readonly httpBaseUrl: URL;
53 readonly captureOutput: boolean;
54}
55
56interface BackendProcessExit {
57 readonly code: Option.Option<number>;
58 readonly reason: string;
59 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
60}
61
62export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{
63 readonly url: URL;
64}> {
65 override get message() {
66 return `Timed out waiting for backend readiness at ${this.url.href}.`;
67 }
68}
69
70class BackendProcessBootstrapEncodeError extends Data.TaggedError(
71 "BackendProcessBootstrapEncodeError",
72)<{
73 readonly cause: Schema.SchemaError;
74}> {
75 override get message() {
76 return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`;
77 }
78}
79
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}`;
85 }
86}
87
88type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
89
90interface RunBackendProcessOptions extends DesktopBackendStartConfig {
91 readonly readinessTimeout?: Duration.Duration;
92 readonly onStarted?: (pid: number) => Effect.Effect<void>;
93 readonly onReady?: () => Effect.Effect<void>;
94 readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;
95 readonly onOutput?: (
96 streamName: BackendProcessOutputStream,
97 chunk: Uint8Array,
98 ) => Effect.Effect<void>;
99}
100
101export interface DesktopBackendSnapshot {
102 readonly desiredRunning: boolean;
103 readonly ready: boolean;
104 readonly activePid: Option.Option<number>;
105 readonly restartAttempt: number;
106 readonly restartScheduled: boolean;
107}
108
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<
117 DesktopBackendManager,
118 DesktopBackendManagerShape
119>()("t3/desktop/BackendManager") {}
120
121const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } =
122 DesktopObservability.makeComponentLogger("desktop-backend-manager");
123
124interface ActiveBackendRun {
125 readonly id: number;
126 readonly scope: Scope.Closeable;
127 readonly fiber: Option.Option<Fiber.Fiber<void, never>>;
128 readonly pid: Option.Option<number>;
129}
130
131interface BackendManagerState {
132 readonly desiredRunning: boolean;
133 readonly ready: boolean;
134 readonly config: Option.Option<DesktopBackendStartConfig>;
135 readonly active: Option.Option<ActiveBackendRun>;
136 readonly restartAttempt: number;
137 readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>;
138 readonly nextRunId: number;
139}
140
141const initialState: BackendManagerState = {
142 desiredRunning: false,
143 ready: false,
144 config: Option.none(),
145 active: Option.none(),
146 restartAttempt: 0,
147 restartFiber: Option.none(),
148 nextRunId: 1,
149};
150
151const activePid = (active: Option.Option<ActiveBackendRun>): Option.Option<number> =>
152 Option.flatMap(active, (run) => run.pid);
153
154const withActiveRun =
155 (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
156 (state: BackendManagerState): BackendManagerState => ({
157 ...state,
158 active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)),
159 });
160
161const calculateRestartDelay = (attempt: number): Duration.Duration =>
162 Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);
163
164const closeRun = (
165 run: ActiveBackendRun,
166 options?: { readonly timeout?: Duration.Duration },
167): Effect.Effect<void> => {
168 const waitForFiber = Option.match(run.fiber, {
169 onNone: () => Effect.void,
170 onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
171 });
172 const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));
173
174 return (
175 options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close
176 ).pipe(Effect.ignore);
177};
178
179const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (
180 baseUrl: URL,
181 timeout: Duration.Duration,
182): Effect.fn.Return<void, BackendTimeoutError, HttpClient.HttpClient> {
183 const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
184 const client = (yield* HttpClient.HttpClient).pipe(
185 HttpClient.filterStatusOk,
186 HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)),
187 HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)),
188 );
189
190 yield* client.get(readinessUrl).pipe(
191 Effect.asVoid,
192 Effect.timeout(timeout),
193 Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })),
194 );
195});
196
197function describeProcessExit(
198 result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
199): BackendProcessExit {
200 if (Result.isSuccess(result)) {
201 return {
202 code: Option.some(result.success),
203 reason: `code=${result.success}`,
204 result,
205 };
206 }
207
208 return {
209 code: Option.none(),
210 reason: result.failure.message,
211 result,
212 };
213}
214
215function drainBackendOutput(
216 streamName: BackendProcessOutputStream,
217 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
218 onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>,
219): Effect.Effect<void> {
220 return stream.pipe(
221 Stream.runForEach((chunk) => onOutput(streamName, chunk)),
222 Effect.ignore,
223 );
224}
225
226const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
227
228const runBackendProcess = Effect.fn("runBackendProcess")(function* (
229 options: RunBackendProcessOptions,
230): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
231 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
232 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
233 Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })),
234 );
235 const onOutput = options.onOutput ?? (() => Effect.void);
236 const command = ChildProcess.make(
237 options.executablePath,
238 [options.entryPath, "--bootstrap-fd", "3"],
239 {
240 cwd: options.cwd,
241 env: options.env,
242 extendEnv: true,
243 // In Electron main, process.execPath points to the Electron binary.
244 // Run the child in Node mode so this backend process does not become a GUI app instance.
245 stdin: "ignore",
246 stdout: options.captureOutput ? "pipe" : "inherit",
247 stderr: options.captureOutput ? "pipe" : "inherit",
248 killSignal: "SIGTERM",
249 forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE,
250 additionalFds: {
251 fd3: {
252 type: "input",
253 stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)),
254 },
255 },
256 },
257 );
258
259 const handle = yield* spawner
260 .spawn(command)
261 .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause })));
262
263 yield* options.onStarted?.(handle.pid) ?? Effect.void;
264 if (options.captureOutput) {
265 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);
266 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);
267 }
268 yield* waitForHttpReady(
269 options.httpBaseUrl,
270 options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
271 ).pipe(
272 Effect.tap(() => options.onReady?.() ?? Effect.void),
273 Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),
274 Effect.forkScoped,
275 );
276
277 return describeProcessExit(yield* Effect.result(handle.exitCode));
278});
279
280const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () {
281 const parentScope = yield* Scope.Scope;
282 const fileSystem = yield* FileSystem.FileSystem;
283 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
284 const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog;
285 const desktopState = yield* DesktopState.DesktopState;
286 const desktopWindow = yield* DesktopWindow.DesktopWindow;
287 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
288 const httpClient = yield* HttpClient.HttpClient;
289 const state = yield* Ref.make(initialState);
290 const mutex = yield* Semaphore.make(1);
291
292 const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
293 Ref.update(state, withActiveRun(runId, f));
294
295 const snapshot = Ref.get(state).pipe(
296 Effect.map(
297 (current): DesktopBackendSnapshot => ({
298 desiredRunning: current.desiredRunning,
299 ready: current.ready,
300 activePid: activePid(current.active),
301 restartAttempt: current.restartAttempt,
302 restartScheduled: Option.isSome(current.restartFiber),
303 }),
304 ),
305 );
306 const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config));
307
308 const cancelRestart = Effect.gen(function* () {
309 const restartFiber = yield* Ref.modify(state, (current) => [
310 current.restartFiber,
311 {
312 ...current,
313 restartFiber: Option.none(),
314 },
315 ]);
316
317 yield* Option.match(restartFiber, {
318 onNone: () => Effect.void,
319 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
320 });
321 });
322
323 const start: Effect.Effect<void> = Effect.suspend(() =>
324 mutex.withPermits(1)(
325 Effect.gen(function* () {
326 const current = yield* Ref.get(state);
327 if (Option.isSome(current.active)) {
328 return;
329 }
330
331 yield* Ref.set(desktopState.backendReady, false);
332 const config = yield* configuration.resolve;
333 const entryExists = yield* fileSystem
334 .exists(config.entryPath)
335 .pipe(Effect.orElseSucceed(() => false));
336
337 yield* cancelRestart;
338 yield* Ref.update(state, (latest) => ({
339 ...latest,
340 desiredRunning: true,
341 ready: false,
342 config: Option.some(config),
343 }));
344
345 if (!entryExists) {
346 yield* scheduleRestart(`missing server entry at ${config.entryPath}`);
347 return;
348 }
349
350 const runScope = yield* Scope.make("sequential");
351 const runId = yield* Ref.modify(state, (latest) => [
352 latest.nextRunId,
353 {
354 ...latest,
355 active: Option.some({
356 id: latest.nextRunId,
357 scope: runScope,
358 fiber: Option.none(),
359 pid: Option.none(),
360 } satisfies ActiveBackendRun),
361 nextRunId: latest.nextRunId + 1,
362 },
363 ]);
364
365 const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* (
366 reason: string,
367 ) {
368 yield* mutex.withPermits(1)(
369 Effect.gen(function* () {
370 const { isCurrentRun, nextState, pid } = yield* Ref.modify(
371 state,
372 (
373 latest,
374 ): readonly [
375 {
376 readonly isCurrentRun: boolean;
377 readonly nextState: BackendManagerState;
378 readonly pid: Option.Option<number>;
379 },
380 BackendManagerState,
381 ] => {
382 const currentRun = Option.getOrUndefined(latest.active);
383 if (currentRun?.id !== runId) {
384 return [
385 {
386 isCurrentRun: false,
387 nextState: latest,
388 pid: Option.none<number>(),
389 },
390 latest,
391 ] as const;
392 }
393
394 const next = {
395 ...latest,
396 active: Option.none<ActiveBackendRun>(),
397 ready: false,
398 };
399 return [
400 {
401 isCurrentRun: true,
402 nextState: next,
403 pid: currentRun.pid,
404 },
405 next,
406 ] as const;
407 },
408 );
409
410 if (isCurrentRun) {
411 if (Option.isSome(pid)) {
412 yield* backendOutputLog.writeSessionBoundary({
413 phase: "END",
414 details: `pid=${pid.value} ${reason}`,
415 });
416 }
417 yield* Ref.set(desktopState.backendReady, false);
418 }
419
420 if (isCurrentRun && nextState.desiredRunning) {
421 yield* scheduleRestart(reason);
422 }
423 }),
424 );
425 });
426
427 const program = runBackendProcess({
428 ...config,
429 onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {
430 yield* updateActiveRun(runId, (run) => ({
431 ...run,
432 pid: Option.some(pid),
433 }));
434 yield* backendOutputLog.writeSessionBoundary({
435 phase: "START",
436 details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`,
437 });
438 }),
439 onReady: Effect.fn("desktop.backendManager.onReady")(function* () {
440 const isCurrentRun = yield* Ref.modify(state, (latest) => {
441 const activeRun = Option.getOrUndefined(latest.active);
442 if (activeRun?.id !== runId) {
443 return [false, latest] as const;
444 }
445
446 return [
447 true,
448 {
449 ...latest,
450 restartAttempt: 0,
451 ready: true,
452 },
453 ] as const;
454 });
455 if (!isCurrentRun) {
456 return;
457 }
458
459 yield* Ref.set(desktopState.backendReady, true);
460 yield* desktopWindow.handleBackendReady.pipe(
461 Effect.catch((error) =>
462 logBackendManagerError("failed to open main window after backend readiness", {
463 message: error.message,
464 }),
465 ),
466 );
467 }),
468 onReadinessFailure: (error) =>
469 logBackendManagerWarning("backend readiness check failed during bootstrap", {
470 error: error.message,
471 }),
472 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
473 }).pipe(
474 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
475 Effect.provideService(HttpClient.HttpClient, httpClient),
476 Scope.provide(runScope),
477 Effect.matchEffect({
478 onFailure: (error) => finalizeRun(error.message),
479 onSuccess: (exit) => finalizeRun(exit.reason),
480 }),
481 Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
482 );
483
484 const fiber = yield* Effect.forkIn(program, parentScope);
485 yield* updateActiveRun(runId, (run) => ({
486 ...run,
487 fiber: Option.some(fiber),
488 }));
489 }),
490 ),
491 ).pipe(Effect.withSpan("desktop.backendManager.start"));
492
493 const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* (
494 reason: string,
495 ) {
496 const scheduled = yield* Ref.modify(state, (latest) => {
497 if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) {
498 return [Option.none<Duration.Duration>(), latest] as const;
499 }
500
501 const delay = calculateRestartDelay(latest.restartAttempt);
502 return [
503 Option.some(delay),
504 {
505 ...latest,
506 restartAttempt: latest.restartAttempt + 1,
507 },
508 ] as const;
509 });
510
511 yield* Option.match(scheduled, {
512 onNone: () => Effect.void,
513 onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) {
514 yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", {
515 reason,
516 delayMs: Duration.toMillis(delay),
517 });
518 const restartFiber = yield* Effect.forkIn(
519 Effect.sleep(delay).pipe(
520 Effect.andThen(
521 Ref.modify(state, (latest) => {
522 const shouldRestart = latest.desiredRunning;
523 return [
524 shouldRestart,
525 {
526 ...latest,
527 restartFiber: Option.none(),
528 },
529 ] as const;
530 }),
531 ),
532 Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),
533 Effect.catchCause((cause) =>
534 logBackendManagerError("desktop backend restart fiber failed", {
535 cause: Cause.pretty(cause),
536 }),
537 ),
538 ),
539 parentScope,
540 );
541 yield* Ref.update(state, (latest) =>
542 Option.isNone(latest.restartFiber)
543 ? {
544 ...latest,
545 restartFiber: Option.some(restartFiber),
546 }
547 : latest,
548 );
549 }),
550 });
551 });
552
553 const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: {
554 readonly timeout?: Duration.Duration;
555 }) {
556 const { active, restartFiber } = yield* mutex.withPermits(1)(
557 Effect.gen(function* () {
558 const result = yield* Ref.modify(state, (latest) => [
559 {
560 active: latest.active,
561 restartFiber: latest.restartFiber,
562 },
563 {
564 ...latest,
565 desiredRunning: false,
566 ready: false,
567 active: Option.none<ActiveBackendRun>(),
568 restartFiber: Option.none<Fiber.Fiber<void, never>>(),
569 },
570 ]);
571 yield* Ref.set(desktopState.backendReady, false);
572 return result;
573 }),
574 );
575
576 yield* Option.match(restartFiber, {
577 onNone: () => Effect.void,
578 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
579 });
580 yield* Option.match(active, {
581 onNone: () => Effect.void,
582 onSome: (run) => closeRun(run, options),
583 });
584 });
585
586 yield* Effect.addFinalizer(() => stop());
587
588 return DesktopBackendManager.of({
589 start,
590 stop,
591 currentConfig,
592 snapshot,
593 });
594});
595
596export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager());
apps/desktop/src/backendPort.test.ts deleted-109
...@@ -1,109 +0,0 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { resolveDesktopBackendPort } from "./backendPort.ts";
4
5describe("resolveDesktopBackendPort", () => {
6 it("returns the starting port when it is available", async () => {
7 const canListenOnHost = vi.fn(async (port: number) => port === 3773);
8
9 await expect(
10 resolveDesktopBackendPort({
11 host: "127.0.0.1",
12 startPort: 3773,
13 canListenOnHost,
14 }),
15 ).resolves.toBe(3773);
16
17 expect(canListenOnHost).toHaveBeenCalledTimes(1);
18 expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1");
19 });
20
21 it("increments sequentially until it finds an available port", async () => {
22 const canListenOnHost = vi.fn(async (port: number) => port === 3775);
23
24 await expect(
25 resolveDesktopBackendPort({
26 host: "127.0.0.1",
27 startPort: 3773,
28 canListenOnHost,
29 }),
30 ).resolves.toBe(3775);
31
32 expect(canListenOnHost.mock.calls).toEqual([
33 [3773, "127.0.0.1"],
34 [3774, "127.0.0.1"],
35 [3775, "127.0.0.1"],
36 ]);
37 });
38
39 it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
40 const canListenOnHost = vi.fn(async (port: number, host: string) => {
41 if (port === 3773 && host === "127.0.0.1") return true;
42 if (port === 3773 && host === "0.0.0.0") return false;
43 return port === 3774;
44 });
45
46 await expect(
47 resolveDesktopBackendPort({
48 host: "127.0.0.1",
49 requiredHosts: ["0.0.0.0"],
50 startPort: 3773,
51 canListenOnHost,
52 }),
53 ).resolves.toBe(3774);
54
55 expect(canListenOnHost.mock.calls).toEqual([
56 [3773, "127.0.0.1"],
57 [3773, "0.0.0.0"],
58 [3774, "127.0.0.1"],
59 [3774, "0.0.0.0"],
60 ]);
61 });
62
63 it("checks overlapping hosts sequentially to avoid self-interference", async () => {
64 let inFlightCount = 0;
65 const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
66 inFlightCount += 1;
67 const overlapped = inFlightCount > 1;
68 await Promise.resolve();
69 inFlightCount -= 1;
70 return !overlapped;
71 });
72
73 await expect(
74 resolveDesktopBackendPort({
75 host: "127.0.0.1",
76 requiredHosts: ["0.0.0.0", "::"],
77 startPort: 3773,
78 maxPort: 3773,
79 canListenOnHost,
80 }),
81 ).resolves.toBe(3773);
82
83 expect(canListenOnHost.mock.calls).toEqual([
84 [3773, "127.0.0.1"],
85 [3773, "0.0.0.0"],
86 [3773, "::"],
87 ]);
88 });
89
90 it("fails when the scan range is exhausted", async () => {
91 const canListenOnHost = vi.fn(async () => false);
92
93 await expect(
94 resolveDesktopBackendPort({
95 host: "127.0.0.1",
96 startPort: 65534,
97 maxPort: 65535,
98 canListenOnHost,
99 }),
100 ).rejects.toThrow(
101 "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
102 );
103
104 expect(canListenOnHost.mock.calls).toEqual([
105 [65534, "127.0.0.1"],
106 [65535, "127.0.0.1"],
107 ]);
108 });
109});
apps/desktop/src/backendPort.ts deleted-83
...@@ -1,83 +0,0 @@
1import * as Effect from "effect/Effect";
2import { NetService } from "@t3tools/shared/Net";
3
4export const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
5const MAX_TCP_PORT = 65_535;
6
7export interface ResolveDesktopBackendPortOptions {
8 readonly host: string;
9 readonly startPort?: number;
10 readonly maxPort?: number;
11 readonly requiredHosts?: ReadonlyArray<string>;
12 readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
13}
14
15const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> =>
16 Effect.service(NetService).pipe(
17 Effect.flatMap((net) => net.canListenOnHost(port, host)),
18 Effect.provide(NetService.layer),
19 Effect.runPromise,
20 );
21
22const isValidPort = (port: number): boolean =>
23 Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
24
25const normalizeHosts = (
26 host: string,
27 requiredHosts: ReadonlyArray<string>,
28): ReadonlyArray<string> =>
29 Array.from(
30 new Set(
31 [host, ...requiredHosts]
32 .map((candidate) => candidate.trim())
33 .filter((candidate) => candidate.length > 0),
34 ),
35 );
36
37async function canListenOnAllHosts(
38 port: number,
39 hosts: ReadonlyArray<string>,
40 canListenOnHost: (port: number, host: string) => Promise<boolean>,
41): Promise<boolean> {
42 for (const candidateHost of hosts) {
43 if (!(await canListenOnHost(port, candidateHost))) {
44 return false;
45 }
46 }
47
48 return true;
49}
50
51export async function resolveDesktopBackendPort({
52 host,
53 startPort = DEFAULT_DESKTOP_BACKEND_PORT,
54 maxPort = MAX_TCP_PORT,
55 requiredHosts = [],
56 canListenOnHost = defaultCanListenOnHost,
57}: ResolveDesktopBackendPortOptions): Promise<number> {
58 if (!isValidPort(startPort)) {
59 throw new Error(`Invalid desktop backend start port: ${startPort}`);
60 }
61
62 if (!isValidPort(maxPort)) {
63 throw new Error(`Invalid desktop backend max port: ${maxPort}`);
64 }
65
66 if (maxPort < startPort) {
67 throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
68 }
69
70 const hostsToCheck = normalizeHosts(host, requiredHosts);
71
72 // Keep desktop startup predictable across app restarts by probing upward from
73 // the same preferred port instead of picking a fresh ephemeral port.
74 for (let port = startPort; port <= maxPort; port += 1) {
75 if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
76 return port;
77 }
78 }
79
80 throw new Error(
81 `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
82 );
83}
apps/desktop/src/backendReadiness.test.ts deleted-114
...@@ -1,114 +0,0 @@
1import { describe, expect, it, vi } from "vitest";
2
3import {
4 BackendReadinessAbortedError,
5 isBackendReadinessAborted,
6 waitForHttpReady,
7} from "./backendReadiness.ts";
8
9describe("waitForHttpReady", () => {
10 it("returns once the backend serves the requested readiness path", async () => {
11 const fetchImpl = vi
12 .fn<typeof fetch>()
13 .mockResolvedValueOnce(new Response(null, { status: 503 }))
14 .mockResolvedValueOnce(new Response(null, { status: 200 }));
15
16 await waitForHttpReady("http://127.0.0.1:3773", {
17 fetchImpl,
18 timeoutMs: 1_000,
19 intervalMs: 0,
20 });
21
22 expect(fetchImpl).toHaveBeenCalledTimes(2);
23 expect(fetchImpl).toHaveBeenNthCalledWith(
24 1,
25 "http://127.0.0.1:3773/",
26 expect.objectContaining({ redirect: "manual" }),
27 );
28 });
29
30 it("retries after a readiness request stalls past the per-request timeout", async () => {
31 const fetchImpl = vi
32 .fn<typeof fetch>()
33 .mockImplementationOnce(
34 (_input, init) =>
35 new Promise((_resolve, reject) => {
36 init?.signal?.addEventListener(
37 "abort",
38 () => {
39 reject(new Error("request timed out"));
40 },
41 { once: true },
42 );
43 }) as ReturnType<typeof fetch>,
44 )
45 .mockResolvedValueOnce(new Response(null, { status: 200 }));
46
47 await waitForHttpReady("http://127.0.0.1:3773", {
48 fetchImpl,
49 timeoutMs: 100,
50 intervalMs: 0,
51 requestTimeoutMs: 1,
52 });
53
54 expect(fetchImpl).toHaveBeenCalledTimes(2);
55 });
56
57 it("aborts an in-flight readiness wait", async () => {
58 const controller = new AbortController();
59 const fetchImpl = vi.fn<typeof fetch>().mockImplementation(
60 () =>
61 new Promise((_resolve, reject) => {
62 controller.signal.addEventListener(
63 "abort",
64 () => {
65 reject(new BackendReadinessAbortedError());
66 },
67 { once: true },
68 );
69 }) as ReturnType<typeof fetch>,
70 );
71
72 const waitPromise = waitForHttpReady("http://127.0.0.1:3773", {
73 fetchImpl,
74 timeoutMs: 1_000,
75 intervalMs: 0,
76 signal: controller.signal,
77 });
78
79 controller.abort();
80
81 await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError);
82 });
83
84 it("recognizes aborted readiness errors", () => {
85 expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
86 expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
87 });
88
89 it("supports custom readiness predicates", async () => {
90 const fetchImpl = vi
91 .fn<typeof fetch>()
92 .mockResolvedValueOnce(new Response(null, { status: 200 }))
93 .mockResolvedValueOnce(new Response(null, { status: 204 }));
94
95 await waitForHttpReady("http://127.0.0.1:3773", {
96 fetchImpl,
97 timeoutMs: 1_000,
98 intervalMs: 0,
99 path: "/api/healthz",
100 isReady: (response) => response.status === 204,
101 });
102
103 expect(fetchImpl).toHaveBeenNthCalledWith(
104 1,
105 "http://127.0.0.1:3773/api/healthz",
106 expect.objectContaining({ redirect: "manual" }),
107 );
108 expect(fetchImpl).toHaveBeenNthCalledWith(
109 2,
110 "http://127.0.0.1:3773/api/healthz",
111 expect.objectContaining({ redirect: "manual" }),
112 );
113 });
114});
apps/desktop/src/backendReadiness.ts deleted-107
...@@ -1,107 +0,0 @@
1export interface WaitForHttpReadyOptions {
2 readonly timeoutMs?: number;
3 readonly intervalMs?: number;
4 readonly requestTimeoutMs?: number;
5 readonly fetchImpl?: typeof fetch;
6 readonly signal?: AbortSignal;
7 readonly path?: string;
8 readonly isReady?: (response: Response) => boolean;
9}
10
11const DEFAULT_TIMEOUT_MS = 30_000;
12const DEFAULT_INTERVAL_MS = 100;
13const DEFAULT_REQUEST_TIMEOUT_MS = 1_000;
14
15export class BackendReadinessAbortedError extends Error {
16 constructor() {
17 super("Backend readiness wait was aborted.");
18 this.name = "BackendReadinessAbortedError";
19 }
20}
21
22function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
23 return new Promise<void>((resolve, reject) => {
24 const timer = setTimeout(() => {
25 cleanup();
26 resolve();
27 }, ms);
28
29 const onAbort = () => {
30 cleanup();
31 reject(new BackendReadinessAbortedError());
32 };
33
34 const cleanup = () => {
35 clearTimeout(timer);
36 signal?.removeEventListener("abort", onAbort);
37 };
38
39 if (signal?.aborted) {
40 cleanup();
41 reject(new BackendReadinessAbortedError());
42 return;
43 }
44
45 signal?.addEventListener("abort", onAbort, { once: true });
46 });
47}
48
49export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError {
50 return error instanceof BackendReadinessAbortedError;
51}
52
53export async function waitForHttpReady(
54 baseUrl: string,
55 options?: WaitForHttpReadyOptions,
56): Promise<void> {
57 const fetchImpl = options?.fetchImpl ?? fetch;
58 const signal = options?.signal;
59 const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60 const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
61 const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
62 const readinessPath = options?.path ?? "/";
63 const isReady = options?.isReady ?? ((response: Response) => response.ok);
64 const deadline = Date.now() + timeoutMs;
65
66 for (;;) {
67 if (signal?.aborted) {
68 throw new BackendReadinessAbortedError();
69 }
70
71 const requestController = new AbortController();
72 const requestTimeout = setTimeout(() => {
73 requestController.abort();
74 }, requestTimeoutMs);
75 const abortRequest = () => {
76 requestController.abort();
77 };
78 signal?.addEventListener("abort", abortRequest, { once: true });
79
80 try {
81 const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
82 redirect: "manual",
83 signal: requestController.signal,
84 });
85 if (isReady(response)) {
86 return;
87 }
88 } catch (error) {
89 if (isBackendReadinessAborted(error)) {
90 throw error;
91 }
92 if (signal?.aborted) {
93 throw new BackendReadinessAbortedError();
94 }
95 // Retry until the backend becomes reachable or the deadline expires.
96 } finally {
97 clearTimeout(requestTimeout);
98 signal?.removeEventListener("abort", abortRequest);
99 }
100
101 if (Date.now() >= deadline) {
102 throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`);
103 }
104
105 await delay(intervalMs, signal);
106 }
107}
apps/desktop/src/backendStartupReadiness.test.ts deleted-58
...@@ -1,58 +0,0 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { BackendReadinessAbortedError } from "./backendReadiness.ts";
4import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
5
6describe("waitForBackendStartupReady", () => {
7 it("falls back to the HTTP probe when no listening signal exists", async () => {
8 const waitForHttpReady = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
9 const cancelHttpWait = vi.fn();
10
11 await expect(
12 waitForBackendStartupReady({
13 waitForHttpReady,
14 cancelHttpWait,
15 }),
16 ).resolves.toBe("http");
17
18 expect(waitForHttpReady).toHaveBeenCalledTimes(1);
19 expect(cancelHttpWait).not.toHaveBeenCalled();
20 });
21
22 it("uses the listening signal and cancels the HTTP probe", async () => {
23 let rejectHttpWait: ((error: unknown) => void) | null = null;
24 const waitForHttpReady = vi.fn(
25 () =>
26 new Promise<void>((_resolve, reject) => {
27 rejectHttpWait = reject;
28 }),
29 );
30 const cancelHttpWait = vi.fn(() => {
31 rejectHttpWait?.(new BackendReadinessAbortedError());
32 });
33
34 await expect(
35 waitForBackendStartupReady({
36 listeningPromise: Promise.resolve(),
37 waitForHttpReady,
38 cancelHttpWait,
39 }),
40 ).resolves.toBe("listening");
41
42 expect(waitForHttpReady).toHaveBeenCalledTimes(1);
43 expect(cancelHttpWait).toHaveBeenCalledTimes(1);
44 });
45
46 it("rejects when the listening signal fails before HTTP readiness", async () => {
47 const error = new Error("backend exited");
48 const waitForHttpReady = vi.fn(() => new Promise<void>(() => {}));
49
50 await expect(
51 waitForBackendStartupReady({
52 listeningPromise: Promise.reject(error),
53 waitForHttpReady,
54 cancelHttpWait: vi.fn(),
55 }),
56 ).rejects.toBe(error);
57 });
58});
apps/desktop/src/backendStartupReadiness.ts deleted-56
...@@ -1,56 +0,0 @@
1import { isBackendReadinessAborted } from "./backendReadiness.ts";
2
3export interface WaitForBackendStartupReadyOptions {
4 readonly listeningPromise?: Promise<void> | null;
5 readonly waitForHttpReady: () => Promise<void>;
6 readonly cancelHttpWait: () => void;
7}
8
9export async function waitForBackendStartupReady(
10 options: WaitForBackendStartupReadyOptions,
11): Promise<"listening" | "http"> {
12 const httpReadyPromise = options.waitForHttpReady();
13 const listeningPromise = options.listeningPromise;
14
15 if (!listeningPromise) {
16 await httpReadyPromise;
17 return "http";
18 }
19
20 return await new Promise<"listening" | "http">((resolve, reject) => {
21 let settled = false;
22
23 const settleResolve = (source: "listening" | "http") => {
24 if (settled) {
25 return;
26 }
27 settled = true;
28 if (source === "listening") {
29 options.cancelHttpWait();
30 }
31 resolve(source);
32 };
33
34 const settleReject = (error: unknown) => {
35 if (settled) {
36 return;
37 }
38 settled = true;
39 reject(error);
40 };
41
42 listeningPromise.then(
43 () => settleResolve("listening"),
44 (error) => settleReject(error),
45 );
46 httpReadyPromise.then(
47 () => settleResolve("http"),
48 (error) => {
49 if (settled && isBackendReadinessAborted(error)) {
50 return;
51 }
52 settleReject(error);
53 },
54 );
55 });
56}
apps/desktop/src/clientPersistence.test.ts deleted-250
...@@ -1,250 +0,0 @@
1import * as fs from "node:fs";
2import * as os from "node:os";
3import * as path from "node:path";
4
5import {
6 EnvironmentId,
7 type ClientSettings,
8 type PersistedSavedEnvironmentRecord,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import {
13 readClientSettings,
14 readSavedEnvironmentRegistry,
15 readSavedEnvironmentSecret,
16 removeSavedEnvironmentSecret,
17 writeClientSettings,
18 writeSavedEnvironmentRegistry,
19 writeSavedEnvironmentSecret,
20 type DesktopSecretStorage,
21} from "./clientPersistence.ts";
22
23const tempDirectories: string[] = [];
24
25afterEach(() => {
26 for (const directory of tempDirectories.splice(0)) {
27 fs.rmSync(directory, { recursive: true, force: true });
28 }
29});
30
31function makeTempPath(fileName: string): string {
32 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-"));
33 tempDirectories.push(directory);
34 return path.join(directory, fileName);
35}
36
37function makeSecretStorage(available: boolean): DesktopSecretStorage {
38 return {
39 isEncryptionAvailable: () => available,
40 encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"),
41 decryptString: (value) => {
42 const decoded = value.toString("utf8");
43 if (!decoded.startsWith("enc:")) {
44 throw new Error("invalid secret");
45 }
46 return decoded.slice("enc:".length);
47 },
48 };
49}
50
51const clientSettings: ClientSettings = {
52 autoOpenPlanSidebar: false,
53 confirmThreadArchive: true,
54 confirmThreadDelete: false,
55 dismissedProviderUpdateNotificationKeys: [],
56 diffIgnoreWhitespace: true,
57 diffWordWrap: true,
58 favorites: [],
59 providerModelPreferences: {},
60 sidebarProjectGroupingMode: "repository_path",
61 sidebarProjectGroupingOverrides: {
62 "environment-1:/tmp/project-a": "separate",
63 },
64 sidebarProjectSortOrder: "manual",
65 sidebarThreadSortOrder: "created_at",
66 sidebarThreadPreviewCount: 6,
67 timestampFormat: "24-hour",
68};
69
70const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
71 environmentId: EnvironmentId.make("environment-1"),
72 label: "Remote environment",
73 httpBaseUrl: "https://remote.example.com/",
74 wsBaseUrl: "wss://remote.example.com/",
75 createdAt: "2026-04-09T00:00:00.000Z",
76 lastConnectedAt: "2026-04-09T01:00:00.000Z",
77 desktopSsh: {
78 alias: "devbox",
79 hostname: "devbox.example.com",
80 username: "julius",
81 port: 22,
82 },
83};
84
85describe("clientPersistence", () => {
86 it("persists and reloads client settings", () => {
87 const settingsPath = makeTempPath("client-settings.json");
88
89 writeClientSettings(settingsPath, clientSettings);
90
91 expect(readClientSettings(settingsPath)).toEqual(clientSettings);
92 });
93
94 it("persists and reloads saved environment metadata", () => {
95 const registryPath = makeTempPath("saved-environments.json");
96
97 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
98
99 expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
100 });
101
102 it("persists encrypted saved environment secrets when encryption is available", () => {
103 const registryPath = makeTempPath("saved-environments.json");
104 const secretStorage = makeSecretStorage(true);
105
106 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
107
108 expect(
109 writeSavedEnvironmentSecret({
110 registryPath,
111 environmentId: savedRegistryRecord.environmentId,
112 secret: "bearer-token",
113 secretStorage,
114 }),
115 ).toBe(true);
116
117 expect(
118 readSavedEnvironmentSecret({
119 registryPath,
120 environmentId: savedRegistryRecord.environmentId,
121 secretStorage,
122 }),
123 ).toBe("bearer-token");
124
125 expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({
126 records: [
127 {
128 ...savedRegistryRecord,
129 encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"),
130 },
131 ],
132 });
133 });
134
135 it("preserves existing secrets when encryption is unavailable", () => {
136 const registryPath = makeTempPath("saved-environments.json");
137 const availableSecretStorage = makeSecretStorage(true);
138
139 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
140
141 writeSavedEnvironmentSecret({
142 registryPath,
143 environmentId: savedRegistryRecord.environmentId,
144 secret: "bearer-token",
145 secretStorage: availableSecretStorage,
146 });
147
148 expect(
149 writeSavedEnvironmentSecret({
150 registryPath,
151 environmentId: savedRegistryRecord.environmentId,
152 secret: "next-token",
153 secretStorage: makeSecretStorage(false),
154 }),
155 ).toBe(false);
156
157 expect(
158 readSavedEnvironmentSecret({
159 registryPath,
160 environmentId: savedRegistryRecord.environmentId,
161 secretStorage: availableSecretStorage,
162 }),
163 ).toBe("bearer-token");
164 });
165
166 it("removes saved environment secrets", () => {
167 const registryPath = makeTempPath("saved-environments.json");
168 const secretStorage = makeSecretStorage(true);
169
170 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
171
172 writeSavedEnvironmentSecret({
173 registryPath,
174 environmentId: savedRegistryRecord.environmentId,
175 secret: "bearer-token",
176 secretStorage,
177 });
178
179 removeSavedEnvironmentSecret({
180 registryPath,
181 environmentId: savedRegistryRecord.environmentId,
182 });
183
184 expect(
185 readSavedEnvironmentSecret({
186 registryPath,
187 environmentId: savedRegistryRecord.environmentId,
188 secretStorage,
189 }),
190 ).toBeNull();
191 });
192
193 it("treats malformed secrets documents as empty", () => {
194 const registryPath = makeTempPath("saved-environments.json");
195 fs.writeFileSync(registryPath, "{}\n", "utf8");
196
197 expect(
198 readSavedEnvironmentSecret({
199 registryPath,
200 environmentId: savedRegistryRecord.environmentId,
201 secretStorage: makeSecretStorage(true),
202 }),
203 ).toBeNull();
204
205 expect(() =>
206 removeSavedEnvironmentSecret({
207 registryPath,
208 environmentId: savedRegistryRecord.environmentId,
209 }),
210 ).not.toThrow();
211 });
212
213 it("returns false when writing a secret without metadata", () => {
214 const registryPath = makeTempPath("saved-environments.json");
215
216 expect(
217 writeSavedEnvironmentSecret({
218 registryPath,
219 environmentId: savedRegistryRecord.environmentId,
220 secret: "bearer-token",
221 secretStorage: makeSecretStorage(true),
222 }),
223 ).toBe(false);
224 });
225
226 it("preserves encrypted secrets when metadata is rewritten", () => {
227 const registryPath = makeTempPath("saved-environments.json");
228 const secretStorage = makeSecretStorage(true);
229
230 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
231
232 writeSavedEnvironmentSecret({
233 registryPath,
234 environmentId: savedRegistryRecord.environmentId,
235 secret: "bearer-token",
236 secretStorage,
237 });
238
239 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
240
241 expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
242 expect(
243 readSavedEnvironmentSecret({
244 registryPath,
245 environmentId: savedRegistryRecord.environmentId,
246 secretStorage,
247 }),
248 ).toBe("bearer-token");
249 });
250});
apps/desktop/src/clientPersistence.ts deleted-238
...@@ -1,238 +0,0 @@
1import * as FS from "node:fs";
2import * as Path from "node:path";
3
4import {
5 ClientSettingsSchema,
6 type ClientSettings,
7 type PersistedSavedEnvironmentRecord,
8} from "@t3tools/contracts";
9import { Predicate } from "effect";
10import * as Schema from "effect/Schema";
11
12interface ClientSettingsDocument {
13 readonly settings: ClientSettings;
14}
15
16interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord {
17 readonly encryptedBearerToken?: string;
18}
19
20interface SavedEnvironmentRegistryDocument {
21 readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
22}
23
24export interface DesktopSecretStorage {
25 readonly isEncryptionAvailable: () => boolean;
26 readonly encryptString: (value: string) => Buffer;
27 readonly decryptString: (value: Buffer) => string;
28}
29
30function readJsonFile<T>(filePath: string): T | null {
31 try {
32 if (!FS.existsSync(filePath)) {
33 return null;
34 }
35 return JSON.parse(FS.readFileSync(filePath, "utf8")) as T;
36 } catch {
37 return null;
38 }
39}
40
41function writeJsonFile(filePath: string, value: unknown): void {
42 const directory = Path.dirname(filePath);
43 const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44 FS.mkdirSync(directory, { recursive: true });
45 FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
46 FS.renameSync(tempPath, filePath);
47}
48
49function isPersistedSavedEnvironmentStorageRecord(
50 value: unknown,
51): value is PersistedSavedEnvironmentStorageRecord {
52 return (
53 Predicate.isObject(value) &&
54 typeof value.environmentId === "string" &&
55 typeof value.label === "string" &&
56 typeof value.httpBaseUrl === "string" &&
57 typeof value.wsBaseUrl === "string" &&
58 typeof value.createdAt === "string" &&
59 (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
60 (value.desktopSsh === undefined ||
61 (Predicate.isObject(value.desktopSsh) &&
62 typeof value.desktopSsh.alias === "string" &&
63 typeof value.desktopSsh.hostname === "string" &&
64 (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") &&
65 (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) &&
66 (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
67 );
68}
69
70function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument {
71 const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath);
72 if (!Predicate.isObject(parsed)) {
73 return { records: [] };
74 }
75
76 return {
77 records: Array.isArray(parsed.records)
78 ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord)
79 : [],
80 };
81}
82
83function toPersistedSavedEnvironmentRecord(
84 record: PersistedSavedEnvironmentStorageRecord,
85): PersistedSavedEnvironmentRecord {
86 const nextRecord = {
87 environmentId: record.environmentId,
88 label: record.label,
89 httpBaseUrl: record.httpBaseUrl,
90 wsBaseUrl: record.wsBaseUrl,
91 createdAt: record.createdAt,
92 lastConnectedAt: record.lastConnectedAt,
93 };
94 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
95}
96
97export function readClientSettings(settingsPath: string): ClientSettings | null {
98 const raw = readJsonFile<ClientSettingsDocument>(settingsPath)?.settings;
99 if (!raw) {
100 return null;
101 }
102 try {
103 return Schema.decodeUnknownSync(ClientSettingsSchema)(raw);
104 } catch {
105 return null;
106 }
107}
108
109export function writeClientSettings(settingsPath: string, settings: ClientSettings): void {
110 writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument);
111}
112
113export function readSavedEnvironmentRegistry(
114 registryPath: string,
115): readonly PersistedSavedEnvironmentRecord[] {
116 return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) =>
117 toPersistedSavedEnvironmentRecord(record),
118 );
119}
120
121export function writeSavedEnvironmentRegistry(
122 registryPath: string,
123 records: readonly PersistedSavedEnvironmentRecord[],
124): void {
125 const currentDocument = readSavedEnvironmentRegistryDocument(registryPath);
126 const encryptedBearerTokenById = new Map(
127 currentDocument.records.flatMap((record) =>
128 record.encryptedBearerToken
129 ? [[record.environmentId, record.encryptedBearerToken] as const]
130 : [],
131 ),
132 );
133 writeJsonFile(registryPath, {
134 records: records.map((record) => {
135 const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
136 return encryptedBearerToken
137 ? {
138 environmentId: record.environmentId,
139 label: record.label,
140 httpBaseUrl: record.httpBaseUrl,
141 wsBaseUrl: record.wsBaseUrl,
142 createdAt: record.createdAt,
143 lastConnectedAt: record.lastConnectedAt,
144 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
145 encryptedBearerToken,
146 }
147 : record;
148 }),
149 } satisfies SavedEnvironmentRegistryDocument);
150}
151
152export function readSavedEnvironmentSecret(input: {
153 readonly registryPath: string;
154 readonly environmentId: string;
155 readonly secretStorage: DesktopSecretStorage;
156}): string | null {
157 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
158 const encoded = document.records.find(
159 (record) => record.environmentId === input.environmentId,
160 )?.encryptedBearerToken;
161 if (!encoded) {
162 return null;
163 }
164
165 if (!input.secretStorage.isEncryptionAvailable()) {
166 return null;
167 }
168
169 try {
170 return input.secretStorage.decryptString(Buffer.from(encoded, "base64"));
171 } catch {
172 return null;
173 }
174}
175
176export function writeSavedEnvironmentSecret(input: {
177 readonly registryPath: string;
178 readonly environmentId: string;
179 readonly secret: string;
180 readonly secretStorage: DesktopSecretStorage;
181}): boolean {
182 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
183
184 if (!input.secretStorage.isEncryptionAvailable()) {
185 return false;
186 }
187
188 let found = false;
189
190 writeJsonFile(input.registryPath, {
191 records: document.records.map((record) => {
192 if (record.environmentId !== input.environmentId) {
193 return record;
194 }
195
196 found = true;
197 const encryptedBearerToken = input.secretStorage
198 .encryptString(input.secret)
199 .toString("base64");
200 const nextRecord = {
201 environmentId: record.environmentId,
202 label: record.label,
203 httpBaseUrl: record.httpBaseUrl,
204 wsBaseUrl: record.wsBaseUrl,
205 createdAt: record.createdAt,
206 lastConnectedAt: record.lastConnectedAt,
207 encryptedBearerToken,
208 };
209 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
210 }),
211 } satisfies SavedEnvironmentRegistryDocument);
212 return found;
213}
214
215export function removeSavedEnvironmentSecret(input: {
216 readonly registryPath: string;
217 readonly environmentId: string;
218}): void {
219 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
220 if (
221 !document.records.some(
222 (record) =>
223 record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined,
224 )
225 ) {
226 return;
227 }
228
229 writeJsonFile(input.registryPath, {
230 records: document.records.map((record) => {
231 if (record.environmentId !== input.environmentId) {
232 return record;
233 }
234
235 return toPersistedSavedEnvironmentRecord(record);
236 }),
237 } satisfies SavedEnvironmentRegistryDocument);
238}
apps/desktop/src/confirmDialog.test.ts deleted-57
...@@ -1,57 +0,0 @@
1import type { BrowserWindow } from "electron";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3
4const { showMessageBoxMock } = vi.hoisted(() => ({
5 showMessageBoxMock: vi.fn(),
6}));
7
8vi.mock("electron", () => ({
9 dialog: {
10 showMessageBox: showMessageBoxMock,
11 },
12}));
13
14import { showDesktopConfirmDialog } from "./confirmDialog.ts";
15
16describe("showDesktopConfirmDialog", () => {
17 beforeEach(() => {
18 showMessageBoxMock.mockReset();
19 });
20
21 it("returns false and does not open a dialog for empty messages", async () => {
22 const result = await showDesktopConfirmDialog(" ", null);
23
24 expect(result).toBe(false);
25 expect(showMessageBoxMock).not.toHaveBeenCalled();
26 });
27
28 it("opens a dialog for the focused window and returns true on confirm", async () => {
29 const ownerWindow = { id: 1 } as BrowserWindow;
30 showMessageBoxMock.mockResolvedValue({ response: 1 });
31
32 const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow);
33
34 expect(result).toBe(true);
35 expect(showMessageBoxMock).toHaveBeenCalledWith(
36 ownerWindow,
37 expect.objectContaining({
38 buttons: ["No", "Yes"],
39 message: "Delete worktree?",
40 }),
41 );
42 });
43
44 it("opens an app-level dialog when there is no focused window", async () => {
45 showMessageBoxMock.mockResolvedValue({ response: 0 });
46
47 const result = await showDesktopConfirmDialog("Delete worktree?", null);
48
49 expect(result).toBe(false);
50 expect(showMessageBoxMock).toHaveBeenCalledWith(
51 expect.objectContaining({
52 buttons: ["No", "Yes"],
53 message: "Delete worktree?",
54 }),
55 );
56 });
57});
apps/desktop/src/confirmDialog.ts deleted-26
...@@ -1,26 +0,0 @@
1import { type BrowserWindow, dialog } from "electron";
2
3const CONFIRM_BUTTON_INDEX = 1;
4
5export async function showDesktopConfirmDialog(
6 message: string,
7 ownerWindow: BrowserWindow | null,
8): Promise<boolean> {
9 const normalizedMessage = message.trim();
10 if (normalizedMessage.length === 0) {
11 return false;
12 }
13
14 const options = {
15 type: "question" as const,
16 buttons: ["No", "Yes"],
17 defaultId: 0,
18 cancelId: 0,
19 noLink: true,
20 message: normalizedMessage,
21 };
22 const result = ownerWindow
23 ? await dialog.showMessageBox(ownerWindow, options)
24 : await dialog.showMessageBox(options);
25 return result.response === CONFIRM_BUTTON_INDEX;
26}
apps/desktop/src/desktopSettings.test.ts deleted-230
...@@ -1,230 +0,0 @@
1import * as fs from "node:fs";
2import * as os from "node:os";
3import * as path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import {
8 DEFAULT_DESKTOP_SETTINGS,
9 readDesktopSettings,
10 resolveDefaultDesktopSettings,
11 setDesktopServerExposurePreference,
12 setDesktopTailscaleServePreference,
13 setDesktopUpdateChannelPreference,
14 writeDesktopSettings,
15} from "./desktopSettings.ts";
16
17const tempDirectories: string[] = [];
18
19afterEach(() => {
20 for (const directory of tempDirectories.splice(0)) {
21 fs.rmSync(directory, { recursive: true, force: true });
22 }
23});
24
25function makeSettingsPath() {
26 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-"));
27 tempDirectories.push(directory);
28 return path.join(directory, "desktop-settings.json");
29}
30
31describe("desktopSettings", () => {
32 it("returns defaults when no settings file exists", () => {
33 expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
34 });
35
36 it("defaults packaged nightly builds to the nightly update channel", () => {
37 expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
38 serverExposureMode: "local-only",
39 tailscaleServeEnabled: false,
40 tailscaleServePort: 443,
41 updateChannel: "nightly",
42 updateChannelConfiguredByUser: false,
43 });
44 });
45
46 it("persists and reloads the configured server exposure mode", () => {
47 const settingsPath = makeSettingsPath();
48
49 writeDesktopSettings(settingsPath, {
50 serverExposureMode: "network-accessible",
51 tailscaleServeEnabled: true,
52 tailscaleServePort: 8443,
53 updateChannel: "latest",
54 updateChannelConfiguredByUser: true,
55 });
56
57 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
58 serverExposureMode: "network-accessible",
59 tailscaleServeEnabled: true,
60 tailscaleServePort: 8443,
61 updateChannel: "latest",
62 updateChannelConfiguredByUser: true,
63 });
64 });
65
66 it("preserves the requested network-accessible preference across temporary fallback", () => {
67 expect(
68 setDesktopServerExposurePreference(
69 {
70 serverExposureMode: "local-only",
71 tailscaleServeEnabled: false,
72 tailscaleServePort: 443,
73 updateChannel: "latest",
74 updateChannelConfiguredByUser: false,
75 },
76 "network-accessible",
77 ),
78 ).toEqual({
79 serverExposureMode: "network-accessible",
80 tailscaleServeEnabled: false,
81 tailscaleServePort: 443,
82 updateChannel: "latest",
83 updateChannelConfiguredByUser: false,
84 });
85 });
86
87 it("persists the requested Tailscale Serve preference", () => {
88 expect(
89 setDesktopTailscaleServePreference(
90 {
91 serverExposureMode: "local-only",
92 tailscaleServeEnabled: false,
93 tailscaleServePort: 443,
94 updateChannel: "latest",
95 updateChannelConfiguredByUser: false,
96 },
97 { enabled: true, port: 8443 },
98 ),
99 ).toEqual({
100 serverExposureMode: "local-only",
101 tailscaleServeEnabled: true,
102 tailscaleServePort: 8443,
103 updateChannel: "latest",
104 updateChannelConfiguredByUser: false,
105 });
106 });
107
108 it("preserves the configured Tailscale Serve port when no new port is requested", () => {
109 expect(
110 setDesktopTailscaleServePreference(
111 {
112 serverExposureMode: "local-only",
113 tailscaleServeEnabled: false,
114 tailscaleServePort: 8443,
115 updateChannel: "latest",
116 updateChannelConfiguredByUser: false,
117 },
118 { enabled: true },
119 ),
120 ).toEqual({
121 serverExposureMode: "local-only",
122 tailscaleServeEnabled: true,
123 tailscaleServePort: 8443,
124 updateChannel: "latest",
125 updateChannelConfiguredByUser: false,
126 });
127 });
128
129 it("persists the requested nightly update channel", () => {
130 expect(
131 setDesktopUpdateChannelPreference(
132 {
133 serverExposureMode: "local-only",
134 tailscaleServeEnabled: false,
135 tailscaleServePort: 443,
136 updateChannel: "latest",
137 updateChannelConfiguredByUser: false,
138 },
139 "nightly",
140 ),
141 ).toEqual({
142 serverExposureMode: "local-only",
143 tailscaleServeEnabled: false,
144 tailscaleServePort: 443,
145 updateChannel: "nightly",
146 updateChannelConfiguredByUser: true,
147 });
148 });
149
150 it("falls back to defaults when the settings file is malformed", () => {
151 const settingsPath = makeSettingsPath();
152 fs.writeFileSync(settingsPath, "{not-json", "utf8");
153
154 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
155 });
156
157 it("falls back to the nightly channel for legacy nightly settings without an update track", () => {
158 const settingsPath = makeSettingsPath();
159 fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8");
160
161 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
162 serverExposureMode: "local-only",
163 tailscaleServeEnabled: false,
164 tailscaleServePort: 443,
165 updateChannel: "nightly",
166 updateChannelConfiguredByUser: false,
167 });
168 });
169
170 it("migrates legacy implicit stable settings to nightly when running a nightly build", () => {
171 const settingsPath = makeSettingsPath();
172 fs.writeFileSync(
173 settingsPath,
174 JSON.stringify({
175 serverExposureMode: "local-only",
176 updateChannel: "latest",
177 }),
178 "utf8",
179 );
180
181 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
182 serverExposureMode: "local-only",
183 tailscaleServeEnabled: false,
184 tailscaleServePort: 443,
185 updateChannel: "nightly",
186 updateChannelConfiguredByUser: false,
187 });
188 });
189
190 it("preserves an explicit stable choice on nightly builds", () => {
191 const settingsPath = makeSettingsPath();
192 fs.writeFileSync(
193 settingsPath,
194 JSON.stringify({
195 serverExposureMode: "local-only",
196 updateChannel: "latest",
197 updateChannelConfiguredByUser: true,
198 }),
199 "utf8",
200 );
201
202 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
203 serverExposureMode: "local-only",
204 tailscaleServeEnabled: false,
205 tailscaleServePort: 443,
206 updateChannel: "latest",
207 updateChannelConfiguredByUser: true,
208 });
209 });
210
211 it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => {
212 const settingsPath = makeSettingsPath();
213 fs.writeFileSync(
214 settingsPath,
215 JSON.stringify({
216 tailscaleServeEnabled: true,
217 tailscaleServePort: 0,
218 }),
219 "utf8",
220 );
221
222 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
223 serverExposureMode: "local-only",
224 tailscaleServeEnabled: true,
225 tailscaleServePort: 443,
226 updateChannel: "latest",
227 updateChannelConfiguredByUser: false,
228 });
229 });
230});
apps/desktop/src/desktopSettings.ts deleted-125
...@@ -1,125 +0,0 @@
1import * as FS from "node:fs";
2import * as Path from "node:path";
3import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
4
5import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
6
7export interface DesktopSettings {
8 readonly serverExposureMode: DesktopServerExposureMode;
9 readonly tailscaleServeEnabled: boolean;
10 readonly tailscaleServePort: number;
11 readonly updateChannel: DesktopUpdateChannel;
12 readonly updateChannelConfiguredByUser: boolean;
13}
14
15export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
16
17export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
18 serverExposureMode: "local-only",
19 tailscaleServeEnabled: false,
20 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
21 updateChannel: "latest",
22 updateChannelConfiguredByUser: false,
23};
24
25export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
26 return {
27 ...DEFAULT_DESKTOP_SETTINGS,
28 updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
29 };
30}
31
32export function setDesktopServerExposurePreference(
33 settings: DesktopSettings,
34 requestedMode: DesktopServerExposureMode,
35): DesktopSettings {
36 return settings.serverExposureMode === requestedMode
37 ? settings
38 : {
39 ...settings,
40 serverExposureMode: requestedMode,
41 };
42}
43
44export function setDesktopTailscaleServePreference(
45 settings: DesktopSettings,
46 input: { readonly enabled: boolean; readonly port?: number },
47): DesktopSettings {
48 const port =
49 input.port === undefined
50 ? settings.tailscaleServePort
51 : normalizeTailscaleServePort(input.port);
52 return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
53 ? settings
54 : {
55 ...settings,
56 tailscaleServeEnabled: input.enabled,
57 tailscaleServePort: port,
58 };
59}
60
61export function normalizeTailscaleServePort(value: unknown): number {
62 return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
63 ? value
64 : DEFAULT_TAILSCALE_SERVE_PORT;
65}
66
67export function setDesktopUpdateChannelPreference(
68 settings: DesktopSettings,
69 requestedChannel: DesktopUpdateChannel,
70): DesktopSettings {
71 return {
72 ...settings,
73 updateChannel: requestedChannel,
74 updateChannelConfiguredByUser: true,
75 };
76}
77
78export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings {
79 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
80
81 try {
82 if (!FS.existsSync(settingsPath)) {
83 return defaultSettings;
84 }
85
86 const raw = FS.readFileSync(settingsPath, "utf8");
87 const parsed = JSON.parse(raw) as {
88 readonly serverExposureMode?: unknown;
89 readonly tailscaleServeEnabled?: unknown;
90 readonly tailscaleServePort?: unknown;
91 readonly updateChannel?: unknown;
92 readonly updateChannelConfiguredByUser?: unknown;
93 };
94 const parsedUpdateChannel =
95 parsed.updateChannel === "nightly" || parsed.updateChannel === "latest"
96 ? parsed.updateChannel
97 : null;
98 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
99 const updateChannelConfiguredByUser =
100 parsed.updateChannelConfiguredByUser === true ||
101 (isLegacySettings && parsedUpdateChannel === "nightly");
102
103 return {
104 serverExposureMode:
105 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
106 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
107 tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
108 updateChannel:
109 updateChannelConfiguredByUser && parsedUpdateChannel !== null
110 ? parsedUpdateChannel
111 : defaultSettings.updateChannel,
112 updateChannelConfiguredByUser,
113 };
114 } catch {
115 return defaultSettings;
116 }
117}
118
119export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void {
120 const directory = Path.dirname(settingsPath);
121 const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
122 FS.mkdirSync(directory, { recursive: true });
123 FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
124 FS.renameSync(tempPath, settingsPath);
125}
apps/desktop/src/electron/ElectronApp.test.ts created+109
...@@ -0,0 +1,109 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { beforeEach, vi } from "vitest";
4
5const {
6 appendSwitchMock,
7 exitMock,
8 getAppPathMock,
9 getVersionMock,
10 onMock,
11 quitMock,
12 relaunchMock,
13 removeListenerMock,
14 setAboutPanelOptionsMock,
15 setAppUserModelIdMock,
16 setDesktopNameMock,
17 setDockIconMock,
18 setNameMock,
19 setPathMock,
20 whenReadyMock,
21} = vi.hoisted(() => ({
22 appendSwitchMock: vi.fn(),
23 exitMock: vi.fn(),
24 getAppPathMock: vi.fn(() => "/app"),
25 getVersionMock: vi.fn(() => "1.2.3"),
26 onMock: vi.fn(),
27 quitMock: vi.fn(),
28 relaunchMock: vi.fn(),
29 removeListenerMock: vi.fn(),
30 setAboutPanelOptionsMock: vi.fn(),
31 setAppUserModelIdMock: vi.fn(),
32 setDesktopNameMock: vi.fn(),
33 setDockIconMock: vi.fn(),
34 setNameMock: vi.fn(),
35 setPathMock: vi.fn(),
36 whenReadyMock: vi.fn(() => Promise.resolve()),
37}));
38
39vi.mock("electron", () => ({
40 app: {
41 commandLine: {
42 appendSwitch: appendSwitchMock,
43 },
44 dock: {
45 setIcon: setDockIconMock,
46 },
47 getAppPath: getAppPathMock,
48 getVersion: getVersionMock,
49 isPackaged: true,
50 name: "T3 Code",
51 on: onMock,
52 quit: quitMock,
53 relaunch: relaunchMock,
54 removeListener: removeListenerMock,
55 runningUnderARM64Translation: false,
56 setAboutPanelOptions: setAboutPanelOptionsMock,
57 setAppUserModelId: setAppUserModelIdMock,
58 setDesktopName: setDesktopNameMock,
59 setName: setNameMock,
60 setPath: setPathMock,
61 whenReady: whenReadyMock,
62 exit: exitMock,
63 },
64}));
65
66import * as ElectronApp from "./ElectronApp.ts";
67
68describe("ElectronApp", () => {
69 beforeEach(() => {
70 appendSwitchMock.mockClear();
71 exitMock.mockClear();
72 onMock.mockClear();
73 quitMock.mockClear();
74 relaunchMock.mockClear();
75 removeListenerMock.mockClear();
76 setPathMock.mockClear();
77 });
78
79 it.effect("reads app metadata through the service", () =>
80 Effect.gen(function* () {
81 const electronApp = yield* ElectronApp.ElectronApp;
82 const metadata = yield* electronApp.metadata;
83
84 assert.deepEqual(metadata, {
85 appVersion: "1.2.3",
86 appPath: "/app",
87 isPackaged: true,
88 resourcesPath: process.resourcesPath,
89 runningUnderArm64Translation: false,
90 });
91 }).pipe(Effect.provide(ElectronApp.layer)),
92 );
93
94 it.effect("scopes app event listeners", () =>
95 Effect.gen(function* () {
96 const listener = vi.fn();
97
98 yield* Effect.scoped(
99 Effect.gen(function* () {
100 const electronApp = yield* ElectronApp.ElectronApp;
101 yield* electronApp.on("activate", listener);
102 }),
103 );
104
105 assert.deepEqual(onMock.mock.calls, [["activate", listener]]);
106 assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);
107 }).pipe(Effect.provide(ElectronApp.layer)),
108 );
109});
apps/desktop/src/electron/ElectronApp.ts created+118
...@@ -0,0 +1,118 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Scope from "effect/Scope";
5
6import * as Electron from "electron";
7
8export interface ElectronAppMetadata {
9 readonly appVersion: string;
10 readonly appPath: string;
11 readonly isPackaged: boolean;
12 readonly resourcesPath: string;
13 readonly runningUnderArm64Translation: boolean;
14}
15
16export interface ElectronAppShape {
17 readonly metadata: Effect.Effect<ElectronAppMetadata>;
18 readonly name: Effect.Effect<string>;
19 readonly whenReady: Effect.Effect<void>;
20 readonly quit: Effect.Effect<void>;
21 readonly exit: (code: number) => Effect.Effect<void>;
22 readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>;
23 readonly setPath: (
24 name: Parameters<Electron.App["setPath"]>[0],
25 path: string,
26 ) => Effect.Effect<void>;
27 readonly setName: (name: string) => Effect.Effect<void>;
28 readonly setAboutPanelOptions: (
29 options: Electron.AboutPanelOptionsOptions,
30 ) => Effect.Effect<void>;
31 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
32 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
33 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
34 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
35 readonly on: <Args extends ReadonlyArray<unknown>>(
36 eventName: string,
37 listener: (...args: Args) => void,
38 ) => Effect.Effect<void, never, Scope.Scope>;
39}
40
41export class ElectronApp extends Context.Service<ElectronApp, ElectronAppShape>()(
42 "t3/desktop/electron/App",
43) {}
44
45const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(
46 eventName: string,
47 listener: (...args: Args) => void,
48): Effect.Effect<void, never, Scope.Scope> =>
49 Effect.acquireRelease(
50 Effect.sync(() => {
51 Electron.app.on(eventName as any, listener as any);
52 }),
53 () =>
54 Effect.sync(() => {
55 Electron.app.removeListener(eventName as any, listener as any);
56 }),
57 ).pipe(Effect.asVoid);
58
59const make = ElectronApp.of({
60 metadata: Effect.sync(() => ({
61 appVersion: Electron.app.getVersion(),
62 appPath: Electron.app.getAppPath(),
63 isPackaged: Electron.app.isPackaged,
64 resourcesPath: process.resourcesPath,
65 runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true,
66 })),
67 name: Effect.sync(() => Electron.app.name),
68 whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid),
69 quit: Effect.sync(() => {
70 Electron.app.quit();
71 }),
72 exit: (code) =>
73 Effect.sync(() => {
74 Electron.app.exit(code);
75 }),
76 relaunch: (options) =>
77 Effect.sync(() => {
78 Electron.app.relaunch(options);
79 }),
80 setPath: (name, path) =>
81 Effect.sync(() => {
82 Electron.app.setPath(name, path);
83 }),
84 setName: (name) =>
85 Effect.sync(() => {
86 Electron.app.setName(name);
87 }),
88 setAboutPanelOptions: (options) =>
89 Effect.sync(() => {
90 Electron.app.setAboutPanelOptions(options);
91 }),
92 setAppUserModelId: (id) =>
93 Effect.sync(() => {
94 Electron.app.setAppUserModelId(id);
95 }),
96 setDesktopName: (desktopName) =>
97 Effect.sync(() => {
98 const linuxApp = Electron.app as Electron.App & {
99 setDesktopName?: (desktopName: string) => void;
100 };
101 linuxApp.setDesktopName?.(desktopName);
102 }),
103 setDockIcon: (iconPath) =>
104 Effect.sync(() => {
105 Electron.app.dock?.setIcon(iconPath);
106 }),
107 appendCommandLineSwitch: (switchName, value) =>
108 Effect.sync(() => {
109 if (value === undefined) {
110 Electron.app.commandLine.appendSwitch(switchName);
111 return;
112 }
113 Electron.app.commandLine.appendSwitch(switchName, value);
114 }),
115 on: addScopedAppListener,
116});
117
118export const layer = Layer.succeed(ElectronApp, make);
apps/desktop/src/electron/ElectronDialog.test.ts created+93
...@@ -0,0 +1,93 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import type { BrowserWindow } from "electron";
5import { beforeEach, vi } from "vitest";
6
7import * as ElectronDialog from "./ElectronDialog.ts";
8
9const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({
10 showMessageBoxMock: vi.fn(),
11 showOpenDialogMock: vi.fn(),
12 showErrorBoxMock: vi.fn(),
13}));
14
15vi.mock("electron", () => ({
16 dialog: {
17 showMessageBox: showMessageBoxMock,
18 showOpenDialog: showOpenDialogMock,
19 showErrorBox: showErrorBoxMock,
20 },
21}));
22
23describe("ElectronDialog", () => {
24 beforeEach(() => {
25 showMessageBoxMock.mockReset();
26 showOpenDialogMock.mockReset();
27 showErrorBoxMock.mockReset();
28 });
29
30 it.effect("returns false without opening a confirm dialog for empty messages", () =>
31 Effect.gen(function* () {
32 const dialog = yield* ElectronDialog.ElectronDialog;
33
34 const result = yield* dialog.confirm({
35 message: " ",
36 owner: Option.none(),
37 });
38
39 assert.isFalse(result);
40 assert.equal(showMessageBoxMock.mock.calls.length, 0);
41 }).pipe(Effect.provide(ElectronDialog.layer)),
42 );
43
44 it.effect("opens a confirm dialog for the owner window", () =>
45 Effect.gen(function* () {
46 const owner = { id: 1 } as BrowserWindow;
47 showMessageBoxMock.mockResolvedValue({ response: 1 });
48 const dialog = yield* ElectronDialog.ElectronDialog;
49
50 const result = yield* dialog.confirm({
51 message: "Delete worktree?",
52 owner: Option.some(owner),
53 });
54
55 assert.isTrue(result);
56 assert.deepEqual(showMessageBoxMock.mock.calls[0], [
57 owner,
58 {
59 type: "question",
60 buttons: ["No", "Yes"],
61 defaultId: 0,
62 cancelId: 0,
63 noLink: true,
64 message: "Delete worktree?",
65 },
66 ]);
67 }).pipe(Effect.provide(ElectronDialog.layer)),
68 );
69
70 it.effect("opens an app-level confirm dialog when there is no owner window", () =>
71 Effect.gen(function* () {
72 showMessageBoxMock.mockResolvedValue({ response: 0 });
73 const dialog = yield* ElectronDialog.ElectronDialog;
74
75 const result = yield* dialog.confirm({
76 message: "Delete worktree?",
77 owner: Option.none(),
78 });
79
80 assert.isFalse(result);
81 assert.deepEqual(showMessageBoxMock.mock.calls[0], [
82 {
83 type: "question",
84 buttons: ["No", "Yes"],
85 defaultId: 0,
86 cancelId: 0,
87 noLink: true,
88 message: "Delete worktree?",
89 },
90 ]);
91 }).pipe(Effect.provide(ElectronDialog.layer)),
92 );
93});
apps/desktop/src/electron/ElectronDialog.ts created+84
...@@ -0,0 +1,84 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as Electron from "electron";
7
8const CONFIRM_BUTTON_INDEX = 1;
9
10export interface ElectronDialogPickFolderInput {
11 readonly owner: Option.Option<Electron.BrowserWindow>;
12 readonly defaultPath: Option.Option<string>;
13}
14
15export interface ElectronDialogConfirmInput {
16 readonly owner: Option.Option<Electron.BrowserWindow>;
17 readonly message: string;
18}
19
20export interface ElectronDialogShape {
21 readonly pickFolder: (
22 input: ElectronDialogPickFolderInput,
23 ) => Effect.Effect<Option.Option<string>>;
24 readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect<boolean>;
25 readonly showMessageBox: (
26 options: Electron.MessageBoxOptions,
27 ) => Effect.Effect<Electron.MessageBoxReturnValue>;
28 readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
29}
30
31export class ElectronDialog extends Context.Service<ElectronDialog, ElectronDialogShape>()(
32 "t3/desktop/electron/Dialog",
33) {}
34
35const make = ElectronDialog.of({
36 pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) {
37 const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, {
38 onNone: () => ({
39 properties: ["openDirectory", "createDirectory"],
40 }),
41 onSome: (defaultPath) => ({
42 properties: ["openDirectory", "createDirectory"],
43 defaultPath,
44 }),
45 });
46 const result = yield* Option.match(input.owner, {
47 onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)),
48 onSome: (owner) =>
49 Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)),
50 });
51
52 if (result.canceled) {
53 return Option.none();
54 }
55 return Option.fromNullishOr(result.filePaths[0]);
56 }),
57 confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) {
58 const normalizedMessage = input.message.trim();
59 if (normalizedMessage.length === 0) {
60 return false;
61 }
62
63 const options = {
64 type: "question" as const,
65 buttons: ["No", "Yes"],
66 defaultId: 0,
67 cancelId: 0,
68 noLink: true,
69 message: normalizedMessage,
70 };
71 const result = yield* Option.match(input.owner, {
72 onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)),
73 onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)),
74 });
75 return result.response === CONFIRM_BUTTON_INDEX;
76 }),
77 showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)),
78 showErrorBox: (title, content) =>
79 Effect.sync(() => {
80 Electron.dialog.showErrorBox(title, content);
81 }),
82});
83
84export const layer = Layer.succeed(ElectronDialog, make);
apps/desktop/src/electron/ElectronMenu.test.ts created+119
...@@ -0,0 +1,119 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import type * as Electron from "electron";
5import { beforeEach, vi } from "vitest";
6
7const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted(
8 () => ({
9 buildFromTemplateMock: vi.fn(),
10 createFromNamedImageMock: vi.fn(),
11 setApplicationMenuMock: vi.fn(),
12 }),
13);
14
15vi.mock("electron", () => ({
16 Menu: {
17 buildFromTemplate: buildFromTemplateMock,
18 setApplicationMenu: setApplicationMenuMock,
19 },
20 nativeImage: {
21 createFromNamedImage: createFromNamedImageMock,
22 },
23}));
24
25import * as ElectronMenu from "./ElectronMenu.ts";
26
27describe("ElectronMenu", () => {
28 beforeEach(() => {
29 buildFromTemplateMock.mockReset();
30 createFromNamedImageMock.mockReset();
31 setApplicationMenuMock.mockReset();
32 });
33
34 it.effect("returns none without building a menu when there are no valid items", () =>
35 Effect.gen(function* () {
36 const electronMenu = yield* ElectronMenu.ElectronMenu;
37 const selectedItemId = yield* electronMenu.showContextMenu({
38 window: {} as Electron.BrowserWindow,
39 items: [],
40 position: Option.none(),
41 });
42
43 assert.isTrue(Option.isNone(selectedItemId));
44 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
45 }).pipe(Effect.provide(ElectronMenu.layer)),
46 );
47
48 it.effect("resolves with the clicked leaf item id", () =>
49 Effect.gen(function* () {
50 buildFromTemplateMock.mockImplementation(
51 (template: Electron.MenuItemConstructorOptions[]) => ({
52 popup: () => {
53 const firstItem = template[0];
54 assert.isDefined(firstItem);
55 const click = firstItem.click;
56 if (!click) {
57 throw new Error("Expected menu item to have a click handler.");
58 }
59 click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
60 },
61 }),
62 );
63
64 const electronMenu = yield* ElectronMenu.ElectronMenu;
65 const selectedItemId = yield* electronMenu.showContextMenu({
66 window: {} as Electron.BrowserWindow,
67 items: [{ id: "copy", label: "Copy" }],
68 position: Option.none(),
69 });
70
71 assert.equal(Option.getOrNull(selectedItemId), "copy");
72 }).pipe(Effect.provide(ElectronMenu.layer)),
73 );
74
75 it.effect("resolves with none when the menu closes without a click", () =>
76 Effect.gen(function* () {
77 buildFromTemplateMock.mockImplementation(() => ({
78 popup: (options: Electron.PopupOptions) => {
79 options.callback?.();
80 },
81 }));
82
83 const electronMenu = yield* ElectronMenu.ElectronMenu;
84 const selectedItemId = yield* electronMenu.showContextMenu({
85 window: {} as Electron.BrowserWindow,
86 items: [{ id: "copy", label: "Copy" }],
87 position: Option.some({ x: 10.8, y: 20.2 }),
88 });
89
90 assert.isTrue(Option.isNone(selectedItemId));
91 assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
92 label: "Copy",
93 enabled: true,
94 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
95 });
96 }).pipe(Effect.provide(ElectronMenu.layer)),
97 );
98
99 it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
100 Effect.gen(function* () {
101 const popupMock = vi.fn();
102 buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock }));
103
104 const electronMenu = yield* ElectronMenu.ElectronMenu;
105 const popup = electronMenu.popupTemplate({
106 window: {} as Electron.BrowserWindow,
107 template: [{ label: "Copy" }],
108 });
109
110 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
111 assert.equal(popupMock.mock.calls.length, 0);
112
113 yield* popup;
114
115 assert.equal(buildFromTemplateMock.mock.calls.length, 1);
116 assert.equal(popupMock.mock.calls.length, 1);
117 }).pipe(Effect.provide(ElectronMenu.layer)),
118 );
119});
apps/desktop/src/electron/ElectronMenu.ts created+181
...@@ -0,0 +1,181 @@
1import type { ContextMenuItem } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as Electron from "electron";
8
9export interface ElectronMenuPosition {
10 readonly x: number;
11 readonly y: number;
12}
13
14export interface ElectronMenuContextInput {
15 readonly window: Electron.BrowserWindow;
16 readonly items: readonly ContextMenuItem[];
17 readonly position: Option.Option<ElectronMenuPosition>;
18}
19
20export interface ElectronMenuTemplateInput {
21 readonly window: Electron.BrowserWindow;
22 readonly template: readonly Electron.MenuItemConstructorOptions[];
23}
24
25export interface ElectronMenuShape {
26 readonly setApplicationMenu: (
27 template: readonly Electron.MenuItemConstructorOptions[],
28 ) => Effect.Effect<void>;
29 readonly showContextMenu: (
30 input: ElectronMenuContextInput,
31 ) => Effect.Effect<Option.Option<string>>;
32 readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>;
33}
34
35export class ElectronMenu extends Context.Service<ElectronMenu, ElectronMenuShape>()(
36 "t3/desktop/electron/Menu",
37) {}
38
39function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
40 const normalizedItems: ContextMenuItem[] = [];
41
42 for (const sourceItem of source) {
43 if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
44 continue;
45 }
46
47 const normalizedItem: ContextMenuItem = {
48 id: sourceItem.id,
49 label: sourceItem.label,
50 destructive: sourceItem.destructive === true,
51 disabled: sourceItem.disabled === true,
52 };
53
54 if (sourceItem.children) {
55 const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
56 if (normalizedChildren.length === 0) {
57 continue;
58 }
59 normalizedItem.children = normalizedChildren;
60 }
61
62 normalizedItems.push(normalizedItem);
63 }
64
65 return normalizedItems;
66}
67
68const normalizePosition = (
69 position: Option.Option<ElectronMenuPosition>,
70): Option.Option<ElectronMenuPosition> =>
71 Option.filter(
72 position,
73 ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
74 ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
75
76export const layer = Layer.sync(ElectronMenu, () => {
77 let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
78
79 const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
80 if (process.platform !== "darwin") {
81 return Option.none();
82 }
83 if (destructiveMenuIconCache !== undefined) {
84 return destructiveMenuIconCache;
85 }
86
87 try {
88 const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
89 width: 12,
90 height: 12,
91 });
92 destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
93 } catch {
94 destructiveMenuIconCache = Option.none();
95 }
96
97 return destructiveMenuIconCache;
98 };
99
100 const buildTemplate = (
101 entries: readonly ContextMenuItem[],
102 complete: (selectedItemId: Option.Option<string>) => void,
103 ): Electron.MenuItemConstructorOptions[] => {
104 const template: Electron.MenuItemConstructorOptions[] = [];
105 let hasInsertedDestructiveSeparator = false;
106
107 for (const item of entries) {
108 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
109 template.push({ type: "separator" });
110 hasInsertedDestructiveSeparator = true;
111 }
112
113 const itemOption: Electron.MenuItemConstructorOptions = {
114 label: item.label,
115 enabled: !item.disabled,
116 };
117 if (item.children && item.children.length > 0) {
118 itemOption.submenu = buildTemplate(item.children, complete);
119 } else {
120 itemOption.click = () => complete(Option.some(item.id));
121 }
122 if (item.destructive && (!item.children || item.children.length === 0)) {
123 const destructiveIcon = getDestructiveMenuIcon();
124 if (Option.isSome(destructiveIcon)) {
125 itemOption.icon = destructiveIcon.value;
126 }
127 }
128
129 template.push(itemOption);
130 }
131
132 return template;
133 };
134
135 return ElectronMenu.of({
136 setApplicationMenu: (template) =>
137 Effect.sync(() => {
138 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
139 }),
140 popupTemplate: (input) =>
141 Effect.sync(() => {
142 if (input.template.length === 0) {
143 return;
144 }
145 Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
146 }),
147 showContextMenu: (input) =>
148 Effect.callback<Option.Option<string>>((resume) => {
149 const normalizedItems = normalizeContextMenuItems(input.items);
150 if (normalizedItems.length === 0) {
151 resume(Effect.succeed(Option.none()));
152 return;
153 }
154
155 let completed = false;
156 const complete = (selectedItemId: Option.Option<string>) => {
157 if (completed) {
158 return;
159 }
160 completed = true;
161 resume(Effect.succeed(selectedItemId));
162 };
163
164 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
165 const popupPosition = normalizePosition(input.position);
166 const popupOptions = Option.match(popupPosition, {
167 onNone: (): Electron.PopupOptions => ({
168 window: input.window,
169 callback: () => complete(Option.none()),
170 }),
171 onSome: (position): Electron.PopupOptions => ({
172 window: input.window,
173 x: position.x,
174 y: position.y,
175 callback: () => complete(Option.none()),
176 }),
177 });
178 menu.popup(popupOptions);
179 }),
180 });
181});
apps/desktop/src/electron/ElectronProtocol.test.ts created+105
...@@ -0,0 +1,105 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import type * as Electron from "electron";
6import { beforeEach, vi } from "vitest";
7
8const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } =
9 vi.hoisted(() => ({
10 registerFileProtocolMock: vi.fn(),
11 registerSchemesAsPrivilegedMock: vi.fn(),
12 unregisterProtocolMock: vi.fn(),
13 }));
14
15vi.mock("electron", () => ({
16 protocol: {
17 registerFileProtocol: registerFileProtocolMock,
18 registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock,
19 unregisterProtocol: unregisterProtocolMock,
20 },
21}));
22
23import * as ElectronProtocol from "./ElectronProtocol.ts";
24
25describe("ElectronProtocol", () => {
26 beforeEach(() => {
27 registerFileProtocolMock.mockReset();
28 registerSchemesAsPrivilegedMock.mockReset();
29 unregisterProtocolMock.mockReset();
30 });
31
32 it("normalizes safe desktop protocol pathnames", () => {
33 assert.equal(
34 Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")),
35 "settings/general",
36 );
37 assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret")));
38 });
39
40 it.effect("registers desktop scheme privileges through a layer", () =>
41 Effect.scoped(
42 Layer.build(ElectronProtocol.layerSchemePrivileges).pipe(
43 Effect.andThen(
44 Effect.sync(() => {
45 assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [
46 [
47 [
48 {
49 scheme: "t3",
50 privileges: {
51 standard: true,
52 secure: true,
53 supportFetchAPI: true,
54 corsEnabled: true,
55 },
56 },
57 ],
58 ],
59 ]);
60 }),
61 ),
62 ),
63 ),
64 );
65
66 it.effect("scopes registered file protocols", () =>
67 Effect.gen(function* () {
68 let capturedHandler:
69 | ((
70 request: Electron.ProtocolRequest,
71 callback: (response: Electron.ProtocolResponse) => void,
72 ) => void)
73 | undefined;
74
75 registerFileProtocolMock.mockImplementation((_scheme, handler) => {
76 capturedHandler = handler;
77 return true;
78 });
79
80 const response = yield* Effect.scoped(
81 Effect.gen(function* () {
82 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
83 yield* electronProtocol.registerFileProtocol({
84 scheme: "t3",
85 handler: () => Effect.succeed({ path: "/app/index.html" }),
86 });
87
88 assert.isDefined(capturedHandler);
89 return yield* Effect.callback<Electron.ProtocolResponse>((resume) => {
90 capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) =>
91 resume(Effect.succeed(response)),
92 );
93 });
94 }),
95 );
96
97 assert.deepEqual(response, { path: "/app/index.html" });
98 assert.deepEqual(
99 registerFileProtocolMock.mock.calls.map((call) => call[0]),
100 ["t3"],
101 );
102 assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]);
103 }).pipe(Effect.provide(ElectronProtocol.layer)),
104 );
105});
apps/desktop/src/electron/ElectronProtocol.ts created+272
...@@ -0,0 +1,272 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";
9import * as Scope from "effect/Scope";
10
11import * as Electron from "electron";
12
13import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts";
14
15export const DESKTOP_SCHEME = "t3";
16
17export class ElectronProtocolRegistrationError extends Data.TaggedError(
18 "ElectronProtocolRegistrationError",
19)<{
20 readonly scheme: string;
21 readonly cause: unknown;
22}> {
23 override get message() {
24 return `Failed to register ${this.scheme}: file protocol.`;
25 }
26}
27
28export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError(
29 "ElectronProtocolStaticBundleMissingError",
30)<{}> {
31 override get message() {
32 return "Desktop static bundle missing. Build apps/server (with bundled client) first.";
33 }
34}
35
36export interface ElectronProtocolShape {
37 readonly registerFileProtocol: <E, R>(input: {
38 readonly scheme: string;
39 readonly handler: (
40 request: Electron.ProtocolRequest,
41 ) => Effect.Effect<Electron.ProtocolResponse, E, R>;
42 readonly onFailure?: (
43 request: Electron.ProtocolRequest,
44 cause: Cause.Cause<E>,
45 ) => Electron.ProtocolResponse;
46 }) => Effect.Effect<void, ElectronProtocolRegistrationError, R | Scope.Scope>;
47 readonly registerDesktopFileProtocol: Effect.Effect<
48 void,
49 ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError,
50 FileSystem.FileSystem | DesktopEnvironment | Scope.Scope
51 >;
52}
53
54export class ElectronProtocol extends Context.Service<ElectronProtocol, ElectronProtocolShape>()(
55 "t3/desktop/electron/Protocol",
56) {}
57
58export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option<string> {
59 const segments: string[] = [];
60 for (const segment of rawPath.split("/")) {
61 if (segment.length === 0 || segment === ".") {
62 continue;
63 }
64 if (segment === "..") {
65 return Option.none();
66 }
67 segments.push(segment);
68 }
69 return Option.some(segments.join("/"));
70}
71
72const registerDesktopSchemePrivileges = Effect.sync(() => {
73 Electron.protocol.registerSchemesAsPrivileged([
74 {
75 scheme: DESKTOP_SCHEME,
76 privileges: {
77 standard: true,
78 secure: true,
79 supportFetchAPI: true,
80 corsEnabled: true,
81 },
82 },
83 ]);
84}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"));
85
86export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);
87
88const resolveDesktopStaticDir: Effect.Effect<
89 Option.Option<string>,
90 never,
91 FileSystem.FileSystem | DesktopEnvironment
92> = Effect.gen(function* () {
93 const fileSystem = yield* FileSystem.FileSystem;
94 const environment = yield* DesktopEnvironment;
95 const candidates = [
96 environment.path.join(environment.appRoot, "apps/server/dist/client"),
97 environment.path.join(environment.appRoot, "apps/web/dist"),
98 ];
99 for (const candidate of candidates) {
100 const hasIndex = yield* fileSystem
101 .exists(environment.path.join(candidate, "index.html"))
102 .pipe(Effect.orElseSucceed(() => false));
103 if (hasIndex) {
104 return Option.some(candidate);
105 }
106 }
107 return Option.none<string>();
108});
109
110const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")(
111 function* (
112 staticRoot: string,
113 requestUrl: string,
114 ): Effect.fn.Return<string, never, FileSystem.FileSystem | DesktopEnvironment> {
115 const fileSystem = yield* FileSystem.FileSystem;
116 const environment = yield* DesktopEnvironment;
117 const url = new URL(requestUrl);
118 const rawPath = decodeURIComponent(url.pathname);
119 const normalizedPath = normalizeDesktopProtocolPathname(rawPath);
120 if (Option.isNone(normalizedPath)) {
121 return environment.path.join(staticRoot, "index.html");
122 }
123
124 const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html";
125 const resolvedPath = environment.path.join(staticRoot, requestedPath);
126
127 if (environment.path.extname(resolvedPath)) {
128 return resolvedPath;
129 }
130
131 const nestedIndex = environment.path.join(resolvedPath, "index.html");
132 const nestedIndexExists = yield* fileSystem
133 .exists(nestedIndex)
134 .pipe(Effect.orElseSucceed(() => false));
135 if (nestedIndexExists) {
136 return nestedIndex;
137 }
138
139 return environment.path.join(staticRoot, "index.html");
140 },
141);
142
143function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean {
144 try {
145 const url = new URL(requestUrl);
146 return environment.path.extname(url.pathname).length > 0;
147 } catch {
148 return false;
149 }
150}
151
152const make = Effect.gen(function* () {
153 const registeredProtocols = yield* Ref.make<ReadonlySet<string>>(new Set());
154
155 const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")(
156 function* <E, R>({
157 scheme,
158 handler,
159 onFailure,
160 }: {
161 readonly scheme: string;
162 readonly handler: (
163 request: Electron.ProtocolRequest,
164 ) => Effect.Effect<Electron.ProtocolResponse, E, R>;
165 readonly onFailure?: (
166 request: Electron.ProtocolRequest,
167 cause: Cause.Cause<E>,
168 ) => Electron.ProtocolResponse;
169 }): Effect.fn.Return<void, ElectronProtocolRegistrationError, R | Scope.Scope> {
170 yield* Effect.annotateCurrentSpan({ scheme });
171 const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe(
172 Effect.map((protocols) => protocols.has(scheme)),
173 );
174 if (alreadyRegistered) {
175 return;
176 }
177
178 const context = yield* Effect.context<R>();
179 const runPromise = Effect.runPromiseWith(context);
180
181 yield* Effect.acquireRelease(
182 Effect.try({
183 try: () => {
184 const registered = Electron.protocol.registerFileProtocol(
185 scheme,
186 (request, callback) => {
187 const response = handler(request).pipe(
188 Effect.withSpan("desktop.electron.protocol.handleFileRequest"),
189 Effect.catchCause((cause) =>
190 Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)),
191 ),
192 );
193
194 void runPromise(response).then(callback, () => callback({ error: -2 }));
195 },
196 );
197 if (!registered) {
198 throw new ElectronProtocolRegistrationError({
199 scheme,
200 cause: "registerFileProtocol returned false",
201 });
202 }
203 },
204 catch: (cause) =>
205 cause instanceof ElectronProtocolRegistrationError
206 ? cause
207 : new ElectronProtocolRegistrationError({ scheme, cause }),
208 }).pipe(
209 Effect.andThen(
210 Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)),
211 ),
212 ),
213 () =>
214 Effect.sync(() => {
215 Electron.protocol.unregisterProtocol(scheme);
216 }).pipe(
217 Effect.andThen(
218 Ref.update(registeredProtocols, (protocols) => {
219 const next = new Set(protocols);
220 next.delete(scheme);
221 return next;
222 }),
223 ),
224 ),
225 );
226 },
227 );
228
229 const registerDesktopFileProtocol = Effect.gen(function* () {
230 const environment = yield* DesktopEnvironment;
231 if (environment.isDevelopment) return;
232
233 const staticRoot = yield* resolveDesktopStaticDir;
234 if (Option.isNone(staticRoot)) {
235 return yield* new ElectronProtocolStaticBundleMissingError();
236 }
237
238 const staticRootResolved = environment.path.resolve(staticRoot.value);
239 const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`;
240 const fallbackIndex = environment.path.join(staticRootResolved, "index.html");
241
242 yield* registerFileProtocol({
243 scheme: DESKTOP_SCHEME,
244 handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) {
245 const fileSystem = yield* FileSystem.FileSystem;
246 const environment = yield* DesktopEnvironment;
247 const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url);
248 const resolvedCandidate = environment.path.resolve(candidate);
249 const isInRoot =
250 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
251 const isAssetRequest = isStaticAssetRequest(request.url, environment);
252 const exists = yield* fileSystem
253 .exists(resolvedCandidate)
254 .pipe(Effect.orElseSucceed(() => false));
255
256 if (!isInRoot || !exists) {
257 return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const);
258 }
259
260 return { path: resolvedCandidate } as const;
261 }),
262 onFailure: () => ({ path: fallbackIndex }),
263 });
264 }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol"));
265
266 return ElectronProtocol.of({
267 registerFileProtocol,
268 registerDesktopFileProtocol,
269 });
270});
271
272export const layer = Layer.effect(ElectronProtocol, make);
apps/desktop/src/electron/ElectronSafeStorage.ts created+70
...@@ -0,0 +1,70 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import * as Electron from "electron";
7
8export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(
9 "ElectronSafeStorageAvailabilityError",
10)<{
11 readonly cause: unknown;
12}> {
13 override get message() {
14 return "Electron safe storage failed to check encryption availability.";
15 }
16}
17
18export class ElectronSafeStorageEncryptError extends Data.TaggedError(
19 "ElectronSafeStorageEncryptError",
20)<{
21 readonly cause: unknown;
22}> {
23 override get message() {
24 return "Electron safe storage failed to encrypt a string.";
25 }
26}
27
28export class ElectronSafeStorageDecryptError extends Data.TaggedError(
29 "ElectronSafeStorageDecryptError",
30)<{
31 readonly cause: unknown;
32}> {
33 override get message() {
34 return "Electron safe storage failed to decrypt a string.";
35 }
36}
37
38export interface ElectronSafeStorageShape {
39 readonly isEncryptionAvailable: Effect.Effect<boolean, ElectronSafeStorageAvailabilityError>;
40 readonly encryptString: (
41 value: string,
42 ) => Effect.Effect<Uint8Array, ElectronSafeStorageEncryptError>;
43 readonly decryptString: (
44 value: Uint8Array,
45 ) => Effect.Effect<string, ElectronSafeStorageDecryptError>;
46}
47
48export class ElectronSafeStorage extends Context.Service<
49 ElectronSafeStorage,
50 ElectronSafeStorageShape
51>()("@t3tools/desktop/ElectronSafeStorage") {}
52
53const make = ElectronSafeStorage.of({
54 isEncryptionAvailable: Effect.try({
55 try: () => Electron.safeStorage.isEncryptionAvailable(),
56 catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }),
57 }),
58 encryptString: (value) =>
59 Effect.try({
60 try: () => Electron.safeStorage.encryptString(value),
61 catch: (cause) => new ElectronSafeStorageEncryptError({ cause }),
62 }),
63 decryptString: (value) =>
64 Effect.try({
65 try: () => Electron.safeStorage.decryptString(Buffer.from(value)),
66 catch: (cause) => new ElectronSafeStorageDecryptError({ cause }),
67 }),
68});
69
70export const layer = Layer.succeed(ElectronSafeStorage, make);
apps/desktop/src/electron/ElectronShell.test.ts created+59
...@@ -0,0 +1,59 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { beforeEach, vi } from "vitest";
4
5const { openExternalMock, writeTextMock } = vi.hoisted(() => ({
6 openExternalMock: vi.fn(),
7 writeTextMock: vi.fn(),
8}));
9
10vi.mock("electron", () => ({
11 shell: {
12 openExternal: openExternalMock,
13 },
14 clipboard: {
15 writeText: writeTextMock,
16 },
17}));
18
19import * as ElectronShell from "./ElectronShell.ts";
20
21describe("ElectronShell", () => {
22 beforeEach(() => {
23 openExternalMock.mockReset();
24 writeTextMock.mockReset();
25 });
26
27 it.effect("opens safe external URLs", () =>
28 Effect.gen(function* () {
29 openExternalMock.mockResolvedValue(undefined);
30
31 const electronShell = yield* ElectronShell.ElectronShell;
32 const result = yield* electronShell.openExternal("https://example.com/path");
33
34 assert.equal(result, true);
35 assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]);
36 }).pipe(Effect.provide(ElectronShell.layer)),
37 );
38
39 it.effect("does not open unsafe external URLs", () =>
40 Effect.gen(function* () {
41 const electronShell = yield* ElectronShell.ElectronShell;
42 const result = yield* electronShell.openExternal("file:///etc/passwd");
43
44 assert.equal(result, false);
45 assert.equal(openExternalMock.mock.calls.length, 0);
46 }).pipe(Effect.provide(ElectronShell.layer)),
47 );
48
49 it.effect("returns false when Electron rejects openExternal", () =>
50 Effect.gen(function* () {
51 openExternalMock.mockRejectedValue(new Error("open failed"));
52
53 const electronShell = yield* ElectronShell.ElectronShell;
54 const result = yield* electronShell.openExternal("https://example.com/path");
55
56 assert.equal(result, false);
57 }).pipe(Effect.provide(ElectronShell.layer)),
58 );
59});
apps/desktop/src/electron/ElectronShell.ts created+50
...@@ -0,0 +1,50 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5
6import * as Electron from "electron";
7
8const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
9
10export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
11 if (typeof rawUrl !== "string") {
12 return Option.none();
13 }
14
15 try {
16 const url = new URL(rawUrl);
17 return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none();
18 } catch {
19 return Option.none();
20 }
21}
22
23export interface ElectronShellShape {
24 readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
25 readonly copyText: (text: string) => Effect.Effect<void>;
26}
27
28export class ElectronShell extends Context.Service<ElectronShell, ElectronShellShape>()(
29 "t3/desktop/electron/Shell",
30) {}
31
32const make = ElectronShell.of({
33 openExternal: (rawUrl) =>
34 Option.match(parseSafeExternalUrl(rawUrl), {
35 onNone: () => Effect.succeed(false),
36 onSome: (externalUrl) =>
37 Effect.promise(() =>
38 Electron.shell.openExternal(externalUrl).then(
39 () => true,
40 () => false,
41 ),
42 ),
43 }),
44 copyText: (text) =>
45 Effect.sync(() => {
46 Electron.clipboard.writeText(text);
47 }),
48});
49
50export const layer = Layer.succeed(ElectronShell, make);
apps/desktop/src/electron/ElectronTheme.test.ts created+52
...@@ -0,0 +1,52 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { beforeEach, vi } from "vitest";
4
5const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({
6 onMock: vi.fn(),
7 removeListenerMock: vi.fn(),
8 themeState: {
9 shouldUseDarkColors: true,
10 themeSource: "system",
11 },
12}));
13
14vi.mock("electron", () => ({
15 nativeTheme: {
16 get shouldUseDarkColors() {
17 return themeState.shouldUseDarkColors;
18 },
19 set themeSource(value: string) {
20 themeState.themeSource = value;
21 },
22 on: onMock,
23 removeListener: removeListenerMock,
24 },
25}));
26
27import * as ElectronTheme from "./ElectronTheme.ts";
28
29describe("ElectronTheme", () => {
30 beforeEach(() => {
31 onMock.mockClear();
32 removeListenerMock.mockClear();
33 themeState.shouldUseDarkColors = true;
34 themeState.themeSource = "system";
35 });
36
37 it.effect("scopes native theme update listeners", () =>
38 Effect.gen(function* () {
39 const listener = vi.fn();
40
41 yield* Effect.scoped(
42 Effect.gen(function* () {
43 const electronTheme = yield* ElectronTheme.ElectronTheme;
44 yield* electronTheme.onUpdated(listener);
45 }),
46 );
47
48 assert.deepEqual(onMock.mock.calls, [["updated", listener]]);
49 assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]);
50 }).pipe(Effect.provide(ElectronTheme.layer)),
51 );
52});
apps/desktop/src/electron/ElectronTheme.ts created+40
...@@ -0,0 +1,40 @@
1import type { DesktopTheme } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Scope from "effect/Scope";
6
7import * as Electron from "electron";
8
9export interface ElectronThemeShape {
10 readonly shouldUseDarkColors: Effect.Effect<boolean>;
11 readonly setSource: (theme: DesktopTheme) => Effect.Effect<void>;
12 readonly onUpdated: (listener: () => void) => Effect.Effect<void, never, Scope.Scope>;
13}
14
15export class ElectronTheme extends Context.Service<ElectronTheme, ElectronThemeShape>()(
16 "t3/desktop/electron/Theme",
17) {}
18
19const make = ElectronTheme.of({
20 shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors),
21 setSource: (theme) =>
22 Effect.suspend(() => {
23 Electron.nativeTheme.themeSource = theme;
24 return Effect.void;
25 }),
26 onUpdated: (listener) =>
27 Effect.acquireRelease(
28 Effect.suspend(() => {
29 Electron.nativeTheme.on("updated", listener);
30 return Effect.void;
31 }),
32 () =>
33 Effect.suspend(() => {
34 Electron.nativeTheme.removeListener("updated", listener);
35 return Effect.void;
36 }),
37 ),
38});
39
40export const layer = Layer.succeed(ElectronTheme, make);
apps/desktop/src/electron/ElectronUpdater.test.ts created+79
...@@ -0,0 +1,79 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import { beforeEach, vi } from "vitest";
5
6const { autoUpdaterMock } = vi.hoisted(() => ({
7 autoUpdaterMock: {
8 allowDowngrade: false,
9 allowPrerelease: false,
10 autoDownload: true,
11 autoInstallOnAppQuit: true,
12 channel: "latest",
13 disableDifferentialDownload: false,
14 checkForUpdates: vi.fn(() => Promise.resolve(null)),
15 downloadUpdate: vi.fn(() => Promise.resolve([])),
16 on: vi.fn(),
17 quitAndInstall: vi.fn(),
18 removeListener: vi.fn(),
19 setFeedURL: vi.fn(),
20 },
21}));
22
23vi.mock("electron-updater", () => ({
24 autoUpdater: autoUpdaterMock,
25}));
26
27import * as ElectronUpdater from "./ElectronUpdater.ts";
28
29describe("ElectronUpdater", () => {
30 beforeEach(() => {
31 autoUpdaterMock.allowDowngrade = false;
32 autoUpdaterMock.allowPrerelease = false;
33 autoUpdaterMock.autoDownload = true;
34 autoUpdaterMock.autoInstallOnAppQuit = true;
35 autoUpdaterMock.channel = "latest";
36 autoUpdaterMock.disableDifferentialDownload = false;
37 autoUpdaterMock.checkForUpdates.mockClear();
38 autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null));
39 autoUpdaterMock.downloadUpdate.mockClear();
40 autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([]));
41 autoUpdaterMock.on.mockClear();
42 autoUpdaterMock.quitAndInstall.mockClear();
43 autoUpdaterMock.removeListener.mockClear();
44 autoUpdaterMock.setFeedURL.mockClear();
45 });
46
47 it.effect("scopes updater event listeners", () =>
48 Effect.gen(function* () {
49 const listener = vi.fn();
50
51 yield* Effect.scoped(
52 Effect.gen(function* () {
53 const updater = yield* ElectronUpdater.ElectronUpdater;
54 yield* updater.on("update-available", listener);
55 }),
56 );
57
58 assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]);
59 assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]);
60 }).pipe(Effect.provide(ElectronUpdater.layer)),
61 );
62
63 it.effect("wraps rejected update checks in the method-specific typed error", () =>
64 Effect.gen(function* () {
65 const cause = new Error("network unavailable");
66 autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause));
67 const updater = yield* ElectronUpdater.ElectronUpdater;
68
69 const exit = yield* Effect.exit(updater.checkForUpdates);
70
71 assert.equal(exit._tag, "Failure");
72 if (exit._tag === "Failure") {
73 const error = Cause.squash(exit.cause);
74 assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError);
75 assert.equal(error.cause, cause);
76 }
77 }).pipe(Effect.provide(ElectronUpdater.layer)),
78 );
79});
apps/desktop/src/electron/ElectronUpdater.ts created+139
...@@ -0,0 +1,139 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Scope from "effect/Scope";
6
7import { autoUpdater } from "electron-updater";
8
9type AutoUpdater = typeof autoUpdater;
10
11export type ElectronUpdaterFeedUrl = Parameters<AutoUpdater["setFeedURL"]>[0];
12
13export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError(
14 "ElectronUpdaterCheckForUpdatesError",
15)<{
16 readonly cause: unknown;
17}> {
18 override get message() {
19 return "Electron updater failed to check for updates.";
20 }
21}
22
23export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError(
24 "ElectronUpdaterDownloadUpdateError",
25)<{
26 readonly cause: unknown;
27}> {
28 override get message() {
29 return "Electron updater failed to download the update.";
30 }
31}
32
33export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError(
34 "ElectronUpdaterQuitAndInstallError",
35)<{
36 readonly cause: unknown;
37}> {
38 override get message() {
39 return "Electron updater failed to quit and install the update.";
40 }
41}
42
43export type ElectronUpdaterError =
44 | ElectronUpdaterCheckForUpdatesError
45 | ElectronUpdaterDownloadUpdateError
46 | ElectronUpdaterQuitAndInstallError;
47
48export interface ElectronUpdaterShape {
49 readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect<void>;
50 readonly setAutoDownload: (value: boolean) => Effect.Effect<void>;
51 readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect<void>;
52 readonly setChannel: (channel: string) => Effect.Effect<void>;
53 readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>;
54 readonly allowDowngrade: Effect.Effect<boolean>;
55 readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>;
56 readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
57 readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
58 readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;
59 readonly quitAndInstall: (options: {
60 readonly isSilent: boolean;
61 readonly isForceRunAfter: boolean;
62 }) => Effect.Effect<void, ElectronUpdaterQuitAndInstallError>;
63 readonly on: <Args extends ReadonlyArray<unknown>>(
64 eventName: string,
65 listener: (...args: Args) => void,
66 ) => Effect.Effect<void, never, Scope.Scope>;
67}
68
69export class ElectronUpdater extends Context.Service<ElectronUpdater, ElectronUpdaterShape>()(
70 "t3/desktop/electron/Updater",
71) {}
72
73export const layer = Layer.succeed(ElectronUpdater, {
74 setFeedURL: (options) =>
75 Effect.suspend(() => {
76 autoUpdater.setFeedURL(options);
77 return Effect.void;
78 }),
79 setAutoDownload: (value) =>
80 Effect.suspend(() => {
81 autoUpdater.autoDownload = value;
82 return Effect.void;
83 }),
84 setAutoInstallOnAppQuit: (value) =>
85 Effect.suspend(() => {
86 autoUpdater.autoInstallOnAppQuit = value;
87 return Effect.void;
88 }),
89 setChannel: (channel) =>
90 Effect.suspend(() => {
91 autoUpdater.channel = channel;
92 return Effect.void;
93 }),
94 setAllowPrerelease: (value) =>
95 Effect.suspend(() => {
96 autoUpdater.allowPrerelease = value;
97 return Effect.void;
98 }),
99 allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade),
100 setAllowDowngrade: (value) =>
101 Effect.suspend(() => {
102 autoUpdater.allowDowngrade = value;
103 return Effect.void;
104 }),
105 setDisableDifferentialDownload: (value) =>
106 Effect.suspend(() => {
107 autoUpdater.disableDifferentialDownload = value;
108 return Effect.void;
109 }),
110 checkForUpdates: Effect.tryPromise({
111 try: () => autoUpdater.checkForUpdates(),
112 catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }),
113 }).pipe(Effect.asVoid),
114 downloadUpdate: Effect.tryPromise({
115 try: () => autoUpdater.downloadUpdate(),
116 catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }),
117 }).pipe(Effect.asVoid),
118 quitAndInstall: ({ isSilent, isForceRunAfter }) =>
119 Effect.try({
120 try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter),
121 catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }),
122 }),
123 on: (eventName, listener) => {
124 const eventTarget = autoUpdater as unknown as {
125 on: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
126 removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
127 };
128 const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
129 return Effect.acquireRelease(
130 Effect.sync(() => {
131 eventTarget.on(eventName, untypedListener);
132 }),
133 () =>
134 Effect.sync(() => {
135 eventTarget.removeListener(eventName, untypedListener);
136 }),
137 ).pipe(Effect.asVoid);
138 },
139} satisfies ElectronUpdaterShape);
apps/desktop/src/electron/ElectronWindow.test.ts created+51
...@@ -0,0 +1,51 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import type * as Electron from "electron";
4import { beforeEach, vi } from "vitest";
5
6const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({
7 appFocusMock: vi.fn(),
8 getAllWindowsMock: vi.fn(),
9}));
10
11vi.mock("electron", () => ({
12 app: {
13 focus: appFocusMock,
14 },
15 BrowserWindow: {
16 getAllWindows: getAllWindowsMock,
17 },
18}));
19
20import * as ElectronWindow from "./ElectronWindow.ts";
21
22function makeBrowserWindow(input: { readonly destroyed: boolean }) {
23 return {
24 isDestroyed: vi.fn(() => input.destroyed),
25 } as unknown as Electron.BrowserWindow;
26}
27
28describe("ElectronWindow", () => {
29 beforeEach(() => {
30 appFocusMock.mockReset();
31 getAllWindowsMock.mockReset();
32 });
33
34 it.effect("skips windows destroyed before appearance sync runs", () =>
35 Effect.gen(function* () {
36 const liveWindow = makeBrowserWindow({ destroyed: false });
37 const destroyedWindow = makeBrowserWindow({ destroyed: true });
38 getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]);
39
40 const syncedWindows: Electron.BrowserWindow[] = [];
41 const electronWindow = yield* ElectronWindow.ElectronWindow;
42 yield* electronWindow.syncAllAppearance((window) =>
43 Effect.sync(() => {
44 syncedWindows.push(window);
45 }),
46 );
47
48 assert.deepEqual(syncedWindows, [liveWindow]);
49 }).pipe(Effect.provide(ElectronWindow.layer)),
50 );
51});
apps/desktop/src/electron/ElectronWindow.ts created+135
...@@ -0,0 +1,135 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7
8import * as Electron from "electron";
9
10export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{
11 readonly cause: unknown;
12}> {
13 override get message() {
14 return "Failed to create Electron BrowserWindow.";
15 }
16}
17
18export interface ElectronWindowShape {
19 readonly create: (
20 options: Electron.BrowserWindowConstructorOptions,
21 ) => Effect.Effect<Electron.BrowserWindow, ElectronWindowCreateError>;
22 readonly main: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
23 readonly currentMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
24 readonly focusedMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
25 readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect<void>;
26 readonly clearMain: (window: Option.Option<Electron.BrowserWindow>) => Effect.Effect<void>;
27 readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect<void>;
28 readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect<void>;
29 readonly destroyAll: Effect.Effect<void>;
30 readonly syncAllAppearance: <E, R>(
31 sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,
32 ) => Effect.Effect<void, E, R>;
33}
34
35export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWindowShape>()(
36 "t3/desktop/electron/Window",
37) {}
38
39const make = Effect.gen(function* () {
40 const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
41
42 const liveMain = Ref.get(mainWindowRef).pipe(
43 Effect.map(Option.filter((value) => !value.isDestroyed())),
44 );
45
46 const currentMainOrFirst = Effect.gen(function* () {
47 const main = yield* liveMain;
48 if (Option.isSome(main)) {
49 return main;
50 }
51
52 return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe(
53 Option.filter((window) => !window.isDestroyed()),
54 );
55 });
56
57 const focusedMainOrFirst = Effect.sync(() =>
58 Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe(
59 Option.filter((window) => !window.isDestroyed()),
60 ),
61 ).pipe(
62 Effect.flatMap((focused) =>
63 Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst,
64 ),
65 );
66
67 return ElectronWindow.of({
68 create: (options) =>
69 Effect.try({
70 try: () => new Electron.BrowserWindow(options),
71 catch: (cause) => new ElectronWindowCreateError({ cause }),
72 }),
73 main: liveMain,
74 currentMainOrFirst,
75 focusedMainOrFirst,
76 setMain: (window) => Ref.set(mainWindowRef, Option.some(window)),
77 clearMain: (window) =>
78 Ref.update(mainWindowRef, (current) => {
79 if (Option.isNone(current)) {
80 return current;
81 }
82 if (Option.isSome(window) && current.value !== window.value) {
83 return current;
84 }
85 return Option.none();
86 }),
87 reveal: (window) =>
88 Effect.sync(() => {
89 if (window.isDestroyed()) {
90 return;
91 }
92
93 if (window.isMinimized()) {
94 window.restore();
95 }
96
97 if (!window.isVisible()) {
98 window.show();
99 }
100
101 if (process.platform === "darwin") {
102 Electron.app.focus({ steal: true });
103 }
104
105 window.focus();
106 }),
107 sendAll: (channel, ...args) =>
108 Effect.sync(() => {
109 for (const window of Electron.BrowserWindow.getAllWindows()) {
110 if (window.isDestroyed()) {
111 continue;
112 }
113 window.webContents.send(channel, ...args);
114 }
115 }),
116 destroyAll: Effect.sync(() => {
117 for (const window of Electron.BrowserWindow.getAllWindows()) {
118 window.destroy();
119 }
120 }),
121 syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(
122 sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,
123 ) {
124 const windows = Electron.BrowserWindow.getAllWindows();
125 for (const window of windows) {
126 if (window.isDestroyed()) {
127 continue;
128 }
129 yield* sync(window);
130 }
131 }),
132 });
133});
134
135export const layer = Layer.effect(ElectronWindow, make);
apps/desktop/src/ipc/DesktopIpc.ts created+219
...@@ -0,0 +1,219 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as Scope from "effect/Scope";
5
6export interface DesktopIpcInvokeEvent {}
7
8export interface DesktopIpcSyncEvent {
9 returnValue: unknown;
10}
11
12export type DesktopIpcHandleListener = (
13 event: DesktopIpcInvokeEvent,
14 raw: unknown,
15) => unknown | Promise<unknown>;
16
17export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void;
18
19export interface DesktopIpcMain {
20 removeHandler(channel: string): void;
21 handle(channel: string, listener: DesktopIpcHandleListener): void;
22 removeAllListeners(channel: string): void;
23 on(channel: string, listener: DesktopIpcSyncListener): void;
24}
25
26export interface DesktopIpcMethod<E, R> {
27 readonly channel: string;
28 readonly handler: (raw: unknown) => Effect.Effect<unknown, E, R>;
29}
30
31export interface DesktopSyncIpcMethod<E, R> {
32 readonly channel: string;
33 readonly handler: () => Effect.Effect<unknown, E, R>;
34}
35
36export interface DesktopIpcShape {
37 readonly handle: <E, R>(
38 input: DesktopIpcMethod<E, R>,
39 ) => Effect.Effect<void, never, R | Scope.Scope>;
40 readonly handleSync: <E, R>(
41 input: DesktopSyncIpcMethod<E, R>,
42 ) => Effect.Effect<void, never, R | Scope.Scope>;
43}
44
45export class DesktopIpc extends Context.Service<DesktopIpc, DesktopIpcShape>()("t3/desktop/Ipc") {}
46
47export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape =>
48 DesktopIpc.of({
49 handle: Effect.fn("desktop.ipc.registerInvoke")(function* <E, R>({
50 channel,
51 handler,
52 }: DesktopIpcMethod<E, R>) {
53 yield* Effect.annotateCurrentSpan({ channel });
54 const context = yield* Effect.context<R>();
55 const runPromise = Effect.runPromiseWith(context);
56
57 yield* Effect.acquireRelease(
58 Effect.sync(() => {
59 ipcMain.removeHandler(channel);
60 ipcMain.handle(channel, (_event, raw) =>
61 runPromise(
62 Effect.gen(function* () {
63 yield* Effect.annotateCurrentSpan({ channel });
64 return yield* handler(raw);
65 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
66 ),
67 );
68 }),
69 () => Effect.sync(() => ipcMain.removeHandler(channel)),
70 );
71 }),
72
73 handleSync: Effect.fn("desktop.ipc.registerSync")(function* <E, R>({
74 channel,
75 handler,
76 }: DesktopSyncIpcMethod<E, R>) {
77 yield* Effect.annotateCurrentSpan({ channel });
78 const context = yield* Effect.context<R>();
79 const runSync = Effect.runSyncWith(context);
80
81 yield* Effect.acquireRelease(
82 Effect.sync(() => {
83 ipcMain.removeAllListeners(channel);
84 ipcMain.on(channel, (event) => {
85 event.returnValue = runSync(
86 Effect.gen(function* () {
87 yield* Effect.annotateCurrentSpan({ channel });
88 return yield* handler();
89 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")),
90 );
91 });
92 }),
93 () => Effect.sync(() => ipcMain.removeAllListeners(channel)),
94 );
95 }),
96 });
97
98/**
99 * Convenience helpers for creating IPC methods
100 */
101
102export interface DesktopIpcMethodRegistration<
103 Payload,
104 EncodedPayload,
105 Result,
106 EncodedResult,
107 E,
108 R,
109 PayloadDecodingServices = never,
110 PayloadEncodingServices = never,
111 ResultDecodingServices = never,
112 ResultEncodingServices = never,
113> {
114 readonly channel: string;
115 readonly payload: Schema.Codec<
116 Payload,
117 EncodedPayload,
118 PayloadDecodingServices,
119 PayloadEncodingServices
120 >;
121 readonly result: Schema.Codec<
122 Result,
123 EncodedResult,
124 ResultDecodingServices,
125 ResultEncodingServices
126 >;
127 readonly handler: (input: Payload) => Effect.Effect<Result, E, R>;
128}
129
130export const makeIpcMethod = <
131 Payload,
132 EncodedPayload,
133 Result,
134 EncodedResult,
135 E,
136 R,
137 PayloadDecodingServices = never,
138 PayloadEncodingServices = never,
139 ResultDecodingServices = never,
140 ResultEncodingServices = never,
141>(
142 method: DesktopIpcMethodRegistration<
143 Payload,
144 EncodedPayload,
145 Result,
146 EncodedResult,
147 E,
148 R,
149 PayloadDecodingServices,
150 PayloadEncodingServices,
151 ResultDecodingServices,
152 ResultEncodingServices
153 >,
154): DesktopIpcMethod<
155 E | Schema.SchemaError,
156 R | PayloadDecodingServices | ResultEncodingServices
157> => {
158 const decode = Schema.decodeUnknownEffect(method.payload);
159 const encode = Schema.encodeUnknownEffect(method.result);
160
161 return {
162 channel: method.channel,
163 handler: (raw) =>
164 decode(raw).pipe(
165 Effect.flatMap(method.handler),
166 Effect.flatMap(encode),
167 Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }),
168 ),
169 };
170};
171
172export interface DesktopSyncIpcMethodRegistration<
173 Result,
174 EncodedResult,
175 E,
176 R,
177 ResultDecodingServices = never,
178 ResultEncodingServices = never,
179> {
180 readonly channel: string;
181 readonly result: Schema.Codec<
182 Result,
183 EncodedResult,
184 ResultDecodingServices,
185 ResultEncodingServices
186 >;
187 readonly handler: () => Effect.Effect<Result, E, R>;
188}
189
190export const makeSyncIpcMethod = <
191 Result,
192 EncodedResult,
193 E,
194 R,
195 ResultDecodingServices = never,
196 ResultEncodingServices = never,
197>(
198 method: DesktopSyncIpcMethodRegistration<
199 Result,
200 EncodedResult,
201 E,
202 R,
203 ResultDecodingServices,
204 ResultEncodingServices
205 >,
206): DesktopSyncIpcMethod<E | Schema.SchemaError, R | ResultEncodingServices> => {
207 const encode = Schema.encodeUnknownEffect(method.result);
208
209 return {
210 channel: method.channel,
211 handler: () =>
212 method
213 .handler()
214 .pipe(
215 Effect.flatMap(encode),
216 Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }),
217 ),
218 };
219};
apps/desktop/src/ipc/DesktopIpcHandlers.ts created+84
...@@ -0,0 +1,84 @@
1import * as Effect from "effect/Effect";
2
3import * as DesktopIpc from "./DesktopIpc.ts";
4import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
5import {
6 getSavedEnvironmentRegistry,
7 getSavedEnvironmentSecret,
8 removeSavedEnvironmentSecret,
9 setSavedEnvironmentRegistry,
10 setSavedEnvironmentSecret,
11} from "./methods/savedEnvironments.ts";
12import {
13 getAdvertisedEndpoints,
14 getServerExposureState,
15 setServerExposureMode,
16 setTailscaleServeEnabled,
17} from "./methods/serverExposure.ts";
18import {
19 bootstrapSshBearerSession,
20 disconnectSshEnvironment,
21 discoverSshHosts,
22 ensureSshEnvironment,
23 fetchSshEnvironmentDescriptor,
24 fetchSshSessionState,
25 issueSshWebSocketToken,
26 resolveSshPasswordPrompt,
27} from "./methods/sshEnvironment.ts";
28import {
29 checkForUpdate,
30 downloadUpdate,
31 getUpdateState,
32 installUpdate,
33 setUpdateChannel,
34} from "./methods/updates.ts";
35import {
36 confirm,
37 getAppBranding,
38 getLocalEnvironmentBootstrap,
39 openExternal,
40 pickFolder,
41 setTheme,
42 showContextMenu,
43} from "./methods/window.ts";
44
45export const installDesktopIpcHandlers = Effect.gen(function* () {
46 const ipc = yield* DesktopIpc.DesktopIpc;
47
48 yield* ipc.handleSync(getAppBranding);
49 yield* ipc.handleSync(getLocalEnvironmentBootstrap);
50
51 yield* ipc.handle(getClientSettings);
52 yield* ipc.handle(setClientSettings);
53 yield* ipc.handle(getSavedEnvironmentRegistry);
54 yield* ipc.handle(setSavedEnvironmentRegistry);
55 yield* ipc.handle(getSavedEnvironmentSecret);
56 yield* ipc.handle(setSavedEnvironmentSecret);
57 yield* ipc.handle(removeSavedEnvironmentSecret);
58
59 yield* ipc.handle(discoverSshHosts);
60 yield* ipc.handle(ensureSshEnvironment);
61 yield* ipc.handle(disconnectSshEnvironment);
62 yield* ipc.handle(fetchSshEnvironmentDescriptor);
63 yield* ipc.handle(bootstrapSshBearerSession);
64 yield* ipc.handle(fetchSshSessionState);
65 yield* ipc.handle(issueSshWebSocketToken);
66 yield* ipc.handle(resolveSshPasswordPrompt);
67
68 yield* ipc.handle(getServerExposureState);
69 yield* ipc.handle(setServerExposureMode);
70 yield* ipc.handle(setTailscaleServeEnabled);
71 yield* ipc.handle(getAdvertisedEndpoints);
72
73 yield* ipc.handle(pickFolder);
74 yield* ipc.handle(confirm);
75 yield* ipc.handle(setTheme);
76 yield* ipc.handle(showContextMenu);
77 yield* ipc.handle(openExternal);
78
79 yield* ipc.handle(getUpdateState);
80 yield* ipc.handle(setUpdateChannel);
81 yield* ipc.handle(downloadUpdate);
82 yield* ipc.handle(installUpdate);
83 yield* ipc.handle(checkForUpdate);
84}).pipe(Effect.withSpan("desktop.ipc.installHandlers"));
apps/desktop/src/ipc/channels.ts created+35
...@@ -0,0 +1,35 @@
1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const CONFIRM_CHANNEL = "desktop:confirm";
3export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const MENU_ACTION_CHANNEL = "desktop:menu-action";
7export const UPDATE_STATE_CHANNEL = "desktop:update-state";
8export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
9export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
10export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
11export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
12export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
13export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
14export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
15export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
16export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
17export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
18export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
19export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
20export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
21export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
22export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
23export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
24export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
25export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
26export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
27export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
28export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
29export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
30export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
31export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
32export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
33export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
34export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
35export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
apps/desktop/src/ipc/methods/clientSettings.ts created+28
...@@ -0,0 +1,28 @@
1import { ClientSettingsSchema } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
7import * as IpcChannels from "../channels.ts";
8import { makeIpcMethod } from "../DesktopIpc.ts";
9
10export const getClientSettings = makeIpcMethod({
11 channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL,
12 payload: Schema.Void,
13 result: Schema.NullOr(ClientSettingsSchema),
14 handler: Effect.fn("desktop.ipc.clientSettings.get")(function* () {
15 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
16 return Option.getOrNull(yield* clientSettings.get);
17 }),
18});
19
20export const setClientSettings = makeIpcMethod({
21 channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL,
22 payload: ClientSettingsSchema,
23 result: Schema.Void,
24 handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) {
25 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
26 yield* clientSettings.set(settings);
27 }),
28});
apps/desktop/src/ipc/methods/savedEnvironments.ts created+76
...@@ -0,0 +1,76 @@
1import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts";
7import * as IpcChannels from "../channels.ts";
8import { makeIpcMethod } from "../DesktopIpc.ts";
9
10const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema);
11const NonBlankString = Schema.String.check(
12 Schema.makeFilter((value) =>
13 value.trim().length > 0 ? undefined : "Expected a non-empty string",
14 ),
15);
16
17const SetSavedEnvironmentSecretInput = Schema.Struct({
18 environmentId: EnvironmentId,
19 secret: NonBlankString,
20});
21
22export const getSavedEnvironmentRegistry = makeIpcMethod({
23 channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL,
24 payload: Schema.Void,
25 result: SavedEnvironmentRegistryPayload,
26 handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () {
27 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
28 return yield* savedEnvironments.getRegistry;
29 }),
30});
31
32export const setSavedEnvironmentRegistry = makeIpcMethod({
33 channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL,
34 payload: SavedEnvironmentRegistryPayload,
35 result: Schema.Void,
36 handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) {
37 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
38 yield* savedEnvironments.setRegistry(records);
39 }),
40});
41
42export const getSavedEnvironmentSecret = makeIpcMethod({
43 channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
44 payload: EnvironmentId,
45 result: Schema.NullOr(Schema.String),
46 handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) {
47 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
48 return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId));
49 }),
50});
51
52export const setSavedEnvironmentSecret = makeIpcMethod({
53 channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
54 payload: SetSavedEnvironmentSecretInput,
55 result: Schema.Boolean,
56 handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({
57 environmentId,
58 secret,
59 }) {
60 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
61 return yield* savedEnvironments.setSecret({
62 environmentId,
63 secret,
64 });
65 }),
66});
67
68export const removeSavedEnvironmentSecret = makeIpcMethod({
69 channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
70 payload: EnvironmentId,
71 result: Schema.Void,
72 handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) {
73 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
74 yield* savedEnvironments.removeSecret(environmentId);
75 }),
76});
apps/desktop/src/ipc/methods/serverExposure.ts created+69
...@@ -0,0 +1,69 @@
1import {
2 AdvertisedEndpoint,
3 DesktopServerExposureModeSchema,
4 DesktopServerExposureStateSchema,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Schema from "effect/Schema";
8
9import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
10import * as DesktopServerExposure from "../../serverExposure/DesktopServerExposure.ts";
11import * as IpcChannels from "../channels.ts";
12import { makeIpcMethod } from "../DesktopIpc.ts";
13
14const SetTailscaleServeEnabledInput = Schema.Struct({
15 enabled: Schema.Boolean,
16 port: Schema.optionalKey(Schema.Number),
17});
18
19export const getServerExposureState = makeIpcMethod({
20 channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL,
21 payload: Schema.Void,
22 result: DesktopServerExposureStateSchema,
23 handler: Effect.fn("desktop.ipc.serverExposure.getState")(function* () {
24 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
25 return yield* serverExposure.getState;
26 }),
27});
28
29export const setServerExposureMode = makeIpcMethod({
30 channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL,
31 payload: DesktopServerExposureModeSchema,
32 result: DesktopServerExposureStateSchema,
33 handler: Effect.fn("desktop.ipc.serverExposure.setMode")(function* (mode) {
34 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
35 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
36 const change = yield* serverExposure.setMode(mode);
37 if (change.requiresRelaunch) {
38 yield* lifecycle.relaunch(`serverExposureMode=${mode}`);
39 }
40 return change.state;
41 }),
42});
43
44export const setTailscaleServeEnabled = makeIpcMethod({
45 channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL,
46 payload: SetTailscaleServeEnabledInput,
47 result: DesktopServerExposureStateSchema,
48 handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleServeEnabled")(function* (input) {
49 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
50 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
51 const change = yield* serverExposure.setTailscaleServeEnabled(input);
52 if (change.requiresRelaunch) {
53 yield* lifecycle.relaunch(
54 change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled",
55 );
56 }
57 return change.state;
58 }),
59});
60
61export const getAdvertisedEndpoints = makeIpcMethod({
62 channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL,
63 payload: Schema.Void,
64 result: Schema.Array(AdvertisedEndpoint),
65 handler: Effect.fn("desktop.ipc.serverExposure.getAdvertisedEndpoints")(function* () {
66 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
67 return yield* serverExposure.getAdvertisedEndpoints;
68 }),
69});
apps/desktop/src/ipc/methods/sshEnvironment.ts created+127
...@@ -0,0 +1,127 @@
1import {
2 DesktopDiscoveredSshHostSchema,
3 DesktopSshBearerBootstrapInputSchema,
4 DesktopSshBearerRequestInputSchema,
5 DesktopSshEnvironmentEnsureInputSchema,
6 DesktopSshEnvironmentEnsureResultSchema,
7 DesktopSshEnvironmentTargetSchema,
8 DesktopSshHttpBaseUrlInputSchema,
9 DesktopSshPasswordPromptCancelledType,
10 DesktopSshPasswordPromptResolutionInputSchema,
11 ExecutionEnvironmentDescriptor,
12 AuthBearerBootstrapResult,
13 AuthSessionState,
14 AuthWebSocketTokenResult,
15} from "@t3tools/contracts";
16import * as Effect from "effect/Effect";
17import * as Schema from "effect/Schema";
18
19import * as IpcChannels from "../channels.ts";
20import { makeIpcMethod } from "../DesktopIpc.ts";
21import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
22import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
23import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts";
24
25export const discoverSshHosts = makeIpcMethod({
26 channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL,
27 payload: Schema.Void,
28 result: Schema.Array(DesktopDiscoveredSshHostSchema),
29 handler: Effect.fn("desktop.ipc.sshEnvironment.discoverHosts")(function* () {
30 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
31 return yield* sshEnvironment.discoverHosts();
32 }),
33});
34
35export const ensureSshEnvironment = makeIpcMethod({
36 channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,
37 payload: DesktopSshEnvironmentEnsureInputSchema,
38 result: DesktopSshEnvironmentEnsureResultSchema,
39 handler: Effect.fn("desktop.ipc.sshEnvironment.ensureEnvironment")(function* ({
40 target,
41 options,
42 }) {
43 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
44 return yield* sshEnvironment.ensureEnvironment(target, options).pipe(
45 Effect.catch((error) =>
46 DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error)
47 ? Effect.succeed({
48 type: DesktopSshPasswordPromptCancelledType,
49 message: error.message,
50 })
51 : Effect.fail(error),
52 ),
53 );
54 }),
55});
56
57export const disconnectSshEnvironment = makeIpcMethod({
58 channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL,
59 payload: DesktopSshEnvironmentTargetSchema,
60 result: Schema.Void,
61 handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) {
62 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
63 yield* sshEnvironment.disconnectEnvironment(target);
64 }),
65});
66
67export const fetchSshEnvironmentDescriptor = makeIpcMethod({
68 channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL,
69 payload: DesktopSshHttpBaseUrlInputSchema,
70 result: ExecutionEnvironmentDescriptor,
71 handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) {
72 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
73 return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl });
74 }),
75});
76
77export const bootstrapSshBearerSession = makeIpcMethod({
78 channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
79 payload: DesktopSshBearerBootstrapInputSchema,
80 result: AuthBearerBootstrapResult,
81 handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({
82 httpBaseUrl,
83 credential,
84 }) {
85 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
86 return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential });
87 }),
88});
89
90export const fetchSshSessionState = makeIpcMethod({
91 channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL,
92 payload: DesktopSshBearerRequestInputSchema,
93 result: AuthSessionState,
94 handler: Effect.fn("desktop.ipc.sshEnvironment.fetchSessionState")(function* ({
95 httpBaseUrl,
96 bearerToken,
97 }) {
98 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
99 return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken });
100 }),
101});
102
103export const issueSshWebSocketToken = makeIpcMethod({
104 channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
105 payload: DesktopSshBearerRequestInputSchema,
106 result: AuthWebSocketTokenResult,
107 handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({
108 httpBaseUrl,
109 bearerToken,
110 }) {
111 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
112 return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken });
113 }),
114});
115
116export const resolveSshPasswordPrompt = makeIpcMethod({
117 channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL,
118 payload: DesktopSshPasswordPromptResolutionInputSchema,
119 result: Schema.Void,
120 handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({
121 requestId,
122 password,
123 }) {
124 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
125 yield* prompts.resolve({ requestId, password });
126 }),
127});
apps/desktop/src/ipc/methods/updates.ts created+62
...@@ -0,0 +1,62 @@
1import {
2 DesktopUpdateActionResultSchema,
3 DesktopUpdateChannelSchema,
4 DesktopUpdateCheckResultSchema,
5 DesktopUpdateStateSchema,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9
10import * as DesktopUpdates from "../../updates/DesktopUpdates.ts";
11import * as IpcChannels from "../channels.ts";
12import { makeIpcMethod } from "../DesktopIpc.ts";
13
14export const getUpdateState = makeIpcMethod({
15 channel: IpcChannels.UPDATE_GET_STATE_CHANNEL,
16 payload: Schema.Void,
17 result: DesktopUpdateStateSchema,
18 handler: Effect.fn("desktop.ipc.updates.getState")(function* () {
19 const updates = yield* DesktopUpdates.DesktopUpdates;
20 return yield* updates.getState;
21 }),
22});
23
24export const setUpdateChannel = makeIpcMethod({
25 channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL,
26 payload: DesktopUpdateChannelSchema,
27 result: DesktopUpdateStateSchema,
28 handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) {
29 const updates = yield* DesktopUpdates.DesktopUpdates;
30 return yield* updates.setChannel(channel);
31 }),
32});
33
34export const downloadUpdate = makeIpcMethod({
35 channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL,
36 payload: Schema.Void,
37 result: DesktopUpdateActionResultSchema,
38 handler: Effect.fn("desktop.ipc.updates.download")(function* () {
39 const updates = yield* DesktopUpdates.DesktopUpdates;
40 return yield* updates.download;
41 }),
42});
43
44export const installUpdate = makeIpcMethod({
45 channel: IpcChannels.UPDATE_INSTALL_CHANNEL,
46 payload: Schema.Void,
47 result: DesktopUpdateActionResultSchema,
48 handler: Effect.fn("desktop.ipc.updates.install")(function* () {
49 const updates = yield* DesktopUpdates.DesktopUpdates;
50 return yield* updates.install;
51 }),
52});
53
54export const checkForUpdate = makeIpcMethod({
55 channel: IpcChannels.UPDATE_CHECK_CHANNEL,
56 payload: Schema.Void,
57 result: DesktopUpdateCheckResultSchema,
58 handler: Effect.fn("desktop.ipc.updates.check")(function* () {
59 const updates = yield* DesktopUpdates.DesktopUpdates;
60 return yield* updates.check("web-ui");
61 }),
62});
apps/desktop/src/ipc/methods/window.ts created+135
...@@ -0,0 +1,135 @@
1import {
2 ContextMenuItemSchema,
3 DesktopAppBrandingSchema,
4 DesktopEnvironmentBootstrapSchema,
5 DesktopThemeSchema,
6 PickFolderOptionsSchema,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11
12import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
13import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
14import * as ElectronDialog from "../../electron/ElectronDialog.ts";
15import * as ElectronMenu from "../../electron/ElectronMenu.ts";
16import * as ElectronShell from "../../electron/ElectronShell.ts";
17import * as ElectronTheme from "../../electron/ElectronTheme.ts";
18import * as ElectronWindow from "../../electron/ElectronWindow.ts";
19import * as IpcChannels from "../channels.ts";
20import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts";
21
22const ContextMenuPosition = Schema.Struct({
23 x: Schema.Number,
24 y: Schema.Number,
25});
26
27const ContextMenuInput = Schema.Struct({
28 items: Schema.Array(ContextMenuItemSchema),
29 position: Schema.optionalKey(ContextMenuPosition),
30});
31
32function toWebSocketBaseUrl(httpBaseUrl: URL): string {
33 const url = new URL(httpBaseUrl.href);
34 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
35 return url.href;
36}
37
38export const getAppBranding = makeSyncIpcMethod({
39 channel: IpcChannels.GET_APP_BRANDING_CHANNEL,
40 result: Schema.NullOr(DesktopAppBrandingSchema),
41 handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () {
42 const environment = yield* DesktopEnvironment.DesktopEnvironment;
43 return environment.branding;
44 }),
45});
46
47export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({
48 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL,
49 result: Schema.NullOr(DesktopEnvironmentBootstrapSchema),
50 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () {
51 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
52 const config = yield* backendManager.currentConfig;
53 return Option.match(config, {
54 onNone: () => null,
55 onSome: ({ bootstrap, httpBaseUrl }) => ({
56 label: "Local environment",
57 httpBaseUrl: httpBaseUrl.href,
58 wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),
59 ...(bootstrap.desktopBootstrapToken
60 ? { bootstrapToken: bootstrap.desktopBootstrapToken }
61 : {}),
62 }),
63 });
64 }),
65});
66
67export const pickFolder = makeIpcMethod({
68 channel: IpcChannels.PICK_FOLDER_CHANNEL,
69 payload: Schema.UndefinedOr(PickFolderOptionsSchema),
70 result: Schema.NullOr(Schema.String),
71 handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) {
72 const dialog = yield* ElectronDialog.ElectronDialog;
73 const electronWindow = yield* ElectronWindow.ElectronWindow;
74 const environment = yield* DesktopEnvironment.DesktopEnvironment;
75 const selectedPath = yield* dialog.pickFolder({
76 owner: yield* electronWindow.focusedMainOrFirst,
77 defaultPath: environment.resolvePickFolderDefaultPath(options),
78 });
79 return Option.getOrNull(selectedPath);
80 }),
81});
82
83export const confirm = makeIpcMethod({
84 channel: IpcChannels.CONFIRM_CHANNEL,
85 payload: Schema.String,
86 result: Schema.Boolean,
87 handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) {
88 const dialog = yield* ElectronDialog.ElectronDialog;
89 const electronWindow = yield* ElectronWindow.ElectronWindow;
90 return yield* electronWindow.focusedMainOrFirst.pipe(
91 Effect.flatMap((owner) => dialog.confirm({ owner, message })),
92 );
93 }),
94});
95
96export const setTheme = makeIpcMethod({
97 channel: IpcChannels.SET_THEME_CHANNEL,
98 payload: DesktopThemeSchema,
99 result: Schema.Void,
100 handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) {
101 const electronTheme = yield* ElectronTheme.ElectronTheme;
102 yield* electronTheme.setSource(theme);
103 }),
104});
105
106export const showContextMenu = makeIpcMethod({
107 channel: IpcChannels.CONTEXT_MENU_CHANNEL,
108 payload: ContextMenuInput,
109 result: Schema.NullOr(Schema.String),
110 handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) {
111 const electronMenu = yield* ElectronMenu.ElectronMenu;
112 const electronWindow = yield* ElectronWindow.ElectronWindow;
113 const window = yield* electronWindow.focusedMainOrFirst;
114 if (Option.isNone(window)) {
115 return null;
116 }
117
118 const selectedItemId = yield* electronMenu.showContextMenu({
119 window: window.value,
120 items: input.items,
121 position: Option.fromNullishOr(input.position),
122 });
123 return Option.getOrNull(selectedItemId);
124 }),
125});
126
127export const openExternal = makeIpcMethod({
128 channel: IpcChannels.OPEN_EXTERNAL_CHANNEL,
129 payload: Schema.String,
130 result: Schema.Boolean,
131 handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) {
132 const shell = yield* ElectronShell.ElectronShell;
133 return yield* shell.openExternal(url);
134 }),
135});
apps/desktop/src/main.ts+138-2287
...@@ -1,2298 +1,149 @@...@@ -1,2298 +1,149 @@
1import * as ChildProcess from "node:child_process";1import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2import * as Crypto from "node:crypto";2import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3import * as FS from "node:fs";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as OS from "node:os";4import * as NodeOS from "node:os";
5import * as Path from "node:path";5import * as Effect from "effect/Effect";
66import * as Layer from "effect/Layer";
7import {7import * as Option from "effect/Option";
8 app,8
9 BrowserWindow,9import * as Electron from "electron";
10 type BrowserWindowConstructorOptions,10
11 clipboard,11import * as NetService from "@t3tools/shared/Net";
12 dialog,12import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
13 ipcMain,
14 Menu,
15 nativeImage,
16 nativeTheme,
17 protocol,
18 safeStorage,
19 shell,
20} from "electron";
21import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";
22import type {
23 ClientSettings,
24 DesktopTheme,
25 DesktopAppBranding,
26 DesktopServerExposureMode,
27 DesktopServerExposureState,
28 DesktopUpdateChannel,
29 PersistedSavedEnvironmentRecord,
30 DesktopUpdateActionResult,
31 DesktopUpdateCheckResult,
32 DesktopUpdateState,
33} from "@t3tools/contracts";
34import { autoUpdater } from "electron-updater";
35
36import type { ContextMenuItem } from "@t3tools/contracts";
37import { RotatingFileSink } from "@t3tools/shared/logging";
38import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
39import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";13import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
40import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";
41import {
42 type DesktopSettings,
43 DEFAULT_DESKTOP_SETTINGS,
44 readDesktopSettings,
45 setDesktopServerExposurePreference,
46 setDesktopTailscaleServePreference,
47 setDesktopUpdateChannelPreference,
48 writeDesktopSettings,
49} from "./desktopSettings.ts";
50import {
51 readClientSettings,
52 readSavedEnvironmentRegistry,
53 readSavedEnvironmentSecret,
54 removeSavedEnvironmentSecret,
55 writeClientSettings,
56 writeSavedEnvironmentRegistry,
57 writeSavedEnvironmentSecret,
58} from "./clientPersistence.ts";
59import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
60import { showDesktopConfirmDialog } from "./confirmDialog.ts";
61import {
62 resolveDesktopCoreAdvertisedEndpoints,
63 resolveDesktopServerExposure,
64} from "./serverExposure.ts";
65import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts";
66import { syncShellEnvironment } from "./syncShellEnvironment.ts";
67import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
68import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
69import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";
70import { ServerListeningDetector } from "./serverListeningDetector.ts";
71import {
72 createInitialDesktopUpdateState,
73 reduceDesktopUpdateStateOnCheckFailure,
74 reduceDesktopUpdateStateOnCheckStart,
75 reduceDesktopUpdateStateOnDownloadComplete,
76 reduceDesktopUpdateStateOnDownloadFailure,
77 reduceDesktopUpdateStateOnDownloadProgress,
78 reduceDesktopUpdateStateOnDownloadStart,
79 reduceDesktopUpdateStateOnInstallFailure,
80 reduceDesktopUpdateStateOnNoUpdate,
81 reduceDesktopUpdateStateOnUpdateAvailable,
82} from "./updateMachine.ts";
83import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
84import { resolveDesktopAppBranding } from "./appBranding.ts";
85import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
86import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
87
88syncShellEnvironment();
89
90const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
91const CONFIRM_CHANNEL = "desktop:confirm";
92const SET_THEME_CHANNEL = "desktop:set-theme";
93const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
94const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
95const MENU_ACTION_CHANNEL = "desktop:menu-action";
96const UPDATE_STATE_CHANNEL = "desktop:update-state";
97const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
98const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
99const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
100const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
101const UPDATE_CHECK_CHANNEL = "desktop:update-check";
102const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
103const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
104const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
105const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
106const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
107const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
108const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
109const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
110const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
111const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
112const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
113const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
114const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
115const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
116const STATE_DIR = Path.join(BASE_DIR, "userdata");
117const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
118const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
119const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
120const DESKTOP_SCHEME = "t3";
121const ROOT_DIR = Path.resolve(__dirname, "../../..");
122const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
123// Dev-only SSH launcher override. Set this to an absolute path on the SSH host
124// for a built server entry, for example:
125// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs"
126const DEV_REMOTE_T3_SERVER_ENTRY_PATH =
127 process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? "";
128const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
129 isDevelopment,
130 appVersion: app.getVersion(),
131});
132const APP_DISPLAY_NAME = desktopAppBranding.displayName;
133const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
134const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
135const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";
136const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";
137const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
138const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
139const COMMIT_HASH_DISPLAY_LENGTH = 12;
140const LOG_DIR = Path.join(STATE_DIR, "logs");
141const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
142const LOG_FILE_MAX_FILES = 10;
143const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
144const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
145const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
146const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
147
148function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined {
149 if (typeof rawOptions !== "object" || rawOptions === null) {
150 return undefined;
151 }
152
153 const { initialPath } = rawOptions as { initialPath?: unknown };
154 if (typeof initialPath !== "string") {
155 return undefined;
156 }
157
158 const trimmedPath = initialPath.trim();
159 if (trimmedPath.length === 0) {
160 return undefined;
161 }
162
163 if (trimmedPath === "~") {
164 return OS.homedir();
165 }
166
167 if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
168 return Path.join(OS.homedir(), trimmedPath.slice(2));
169 }
170
171 return Path.resolve(trimmedPath);
172}
173const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
174const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
175const TITLEBAR_HEIGHT = 40;
176const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
177const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
178const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
179
180function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
181 const normalizedItems: ContextMenuItem[] = [];
182
183 for (const sourceItem of source) {
184 if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
185 continue;
186 }
187
188 const normalizedItem: ContextMenuItem = {
189 id: sourceItem.id,
190 label: sourceItem.label,
191 destructive: sourceItem.destructive === true,
192 disabled: sourceItem.disabled === true,
193 };
194
195 if (sourceItem.children) {
196 const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
197 if (normalizedChildren.length === 0) {
198 continue;
199 }
200 normalizedItem.children = normalizedChildren;
201 }
202
203 normalizedItems.push(normalizedItem);
204 }
205
206 return normalizedItems;
207}
208
209type WindowTitleBarOptions = Pick<
210 BrowserWindowConstructorOptions,
211 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
212>;
213
214type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
215type LinuxDesktopNamedApp = Electron.App & {
216 setDesktopName?: (desktopName: string) => void;
217};
218
219let mainWindow: BrowserWindow | null = null;
220let backendProcess: ChildProcess.ChildProcess | null = null;
221let backendPort = 0;
222let backendBindHost = DESKTOP_LOOPBACK_HOST;
223let backendBootstrapToken = "";
224let backendHttpUrl = "";
225let backendWsUrl = "";
226let backendEndpointUrl: string | null = null;
227let backendAdvertisedHost: string | null = null;
228let backendReadinessAbortController: AbortController | null = null;
229let backendInitialWindowOpenInFlight: Promise<void> | null = null;
230let backendListeningDetector: ServerListeningDetector | null = null;
231let restartAttempt = 0;
232let restartTimer: ReturnType<typeof setTimeout> | null = null;
233let isQuitting = false;
234let desktopProtocolRegistered = false;
235let aboutCommitHashCache: string | null | undefined;
236let desktopLogSink: RotatingFileSink | null = null;
237let backendLogSink: RotatingFileSink | null = null;
238let restoreStdIoCapture: (() => void) | null = null;
239let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
240let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
241let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
242
243let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
244const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
245const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
246 platform: process.platform,
247 processArch: process.arch,
248 runningUnderArm64Translation: app.runningUnderARM64Translation === true,
249});
250const initialUpdateState = (): DesktopUpdateState =>
251 createInitialDesktopUpdateState(
252 app.getVersion(),
253 desktopRuntimeInfo,
254 desktopSettings.updateChannel,
255 );
256
257function logTimestamp(): string {
258 return new Date().toISOString();
259}
260
261function logScope(scope: string): string {
262 return `${scope} run=${APP_RUN_ID}`;
263}
264
265function sanitizeLogValue(value: string): string {
266 return value.replace(/\s+/g, " ").trim();
267}
268
269function readPersistedBackendObservabilitySettings(): {
270 readonly otlpTracesUrl: string | undefined;
271 readonly otlpMetricsUrl: string | undefined;
272} {
273 try {
274 if (!FS.existsSync(SERVER_SETTINGS_PATH)) {
275 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
276 }
277 return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8"));
278 } catch (error) {
279 console.warn("[desktop] failed to read persisted backend observability settings", error);
280 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
281 }
282}
283
284function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined {
285 if (!rawPort) {
286 return undefined;
287 }
288
289 const parsedPort = Number.parseInt(rawPort, 10);
290 if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) {
291 return undefined;
292 }
293
294 return parsedPort;
295}
296
297function resolveDesktopDevServerUrl(): string {
298 const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
299 if (!devServerUrl) {
300 throw new Error("VITE_DEV_SERVER_URL is required in desktop development.");
301 }
302
303 return devServerUrl;
304}
305
306function backendChildEnv(): NodeJS.ProcessEnv {
307 const env = { ...process.env };
308 delete env.T3CODE_PORT;
309 delete env.T3CODE_MODE;
310 delete env.T3CODE_NO_BROWSER;
311 delete env.T3CODE_HOST;
312 delete env.T3CODE_DESKTOP_WS_URL;
313 delete env.T3CODE_DESKTOP_LAN_ACCESS;
314 delete env.T3CODE_DESKTOP_LAN_HOST;
315 delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS;
316 delete env.T3CODE_TAILSCALE_SERVE;
317 delete env.T3CODE_TAILSCALE_SERVE_PORT;
318 return env;
319}
320
321function getDesktopServerExposureState(): DesktopServerExposureState {
322 return {
323 mode: desktopServerExposureMode,
324 endpointUrl: backendEndpointUrl,
325 advertisedHost: backendAdvertisedHost,
326 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
327 tailscaleServePort: desktopSettings.tailscaleServePort,
328 };
329}
330
331async function getDesktopAdvertisedEndpoints() {
332 const exposure = resolveDesktopServerExposure({
333 mode: desktopServerExposureMode,
334 port: backendPort,
335 networkInterfaces: OS.networkInterfaces(),
336 ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
337 });
338 const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
339 port: backendPort,
340 exposure,
341 customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
342 });
343 const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
344 port: backendPort,
345 serveEnabled: desktopSettings.tailscaleServeEnabled,
346 servePort: desktopSettings.tailscaleServePort,
347 networkInterfaces: OS.networkInterfaces(),
348 });
349 return [...coreEndpoints, ...tailscaleEndpoints];
350}
351
352function getDesktopSecretStorage() {
353 return {
354 isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
355 encryptString: (value: string) => safeStorage.encryptString(value),
356 decryptString: (value: Buffer) => safeStorage.decryptString(value),
357 } as const;
358}
35914
360function resolveAdvertisedHostOverride(): string | undefined {15import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
361 const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();16import * as DesktopIpc from "./ipc/DesktopIpc.ts";
362 return override && override.length > 0 ? override : undefined;17import * as ElectronApp from "./electron/ElectronApp.ts";
363}18import * as ElectronDialog from "./electron/ElectronDialog.ts";
36419import * as ElectronMenu from "./electron/ElectronMenu.ts";
365function resolveCustomHttpsEndpointUrls(): readonly string[] {20import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
366 return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "")21import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts";
367 .split(",")22import * as ElectronShell from "./electron/ElectronShell.ts";
368 .map((entry) => entry.trim())23import * as ElectronTheme from "./electron/ElectronTheme.ts";
369 .filter((entry) => entry.length > 0);24import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
370}25import * as ElectronWindow from "./electron/ElectronWindow.ts";
37126import * as DesktopApp from "./app/DesktopApp.ts";
372async function applyDesktopServerExposureMode(27import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
373 mode: DesktopServerExposureMode,28import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
374 options?: {29import * as DesktopAssets from "./app/DesktopAssets.ts";
375 readonly persist?: boolean;30import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
376 readonly rejectIfUnavailable?: boolean;31import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";
377 },32import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
378): Promise<DesktopServerExposureState> {33import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
379 const advertisedHostOverride = resolveAdvertisedHostOverride();34import * as DesktopObservability from "./app/DesktopObservability.ts";
380 const requestedMode = mode;35import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts";
381 let exposure = resolveDesktopServerExposure({36import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
382 mode,37import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
383 port: backendPort,38import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
384 networkInterfaces: OS.networkInterfaces(),39import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
385 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),40import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
386 });41import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
38742import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts";
388 if (requestedMode === "network-accessible" && exposure.endpointUrl === null) {43import * as DesktopState from "./app/DesktopState.ts";
389 if (options?.rejectIfUnavailable) {44import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
390 throw new Error("No reachable network address is available for this desktop right now.");45import * as DesktopWindow from "./window/DesktopWindow.ts";
391 }46
392 exposure = resolveDesktopServerExposure({47const desktopEnvironmentLayer = Layer.unwrap(
393 mode: "local-only",48 Effect.gen(function* () {
394 port: backendPort,49 const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe(
395 networkInterfaces: OS.networkInterfaces(),50 Effect.flatMap((app) => app.metadata),
396 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
397 });
398 }
399
400 desktopServerExposureMode = exposure.mode;
401 desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode);
402 backendBindHost = exposure.bindHost;
403 backendHttpUrl = exposure.localHttpUrl;
404 backendWsUrl = exposure.localWsUrl;
405 backendEndpointUrl = exposure.endpointUrl;
406 backendAdvertisedHost = exposure.advertisedHost;
407
408 if (options?.persist) {
409 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
410 }
411
412 return getDesktopServerExposureState();
413}
414
415async function applyDesktopTailscaleServeEnabled(
416 nextSettings: DesktopSettings,
417): Promise<DesktopServerExposureState> {
418 desktopSettings = nextSettings;
419 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
420 relaunchDesktopApp(
421 desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled",
422 );
423 return getDesktopServerExposureState();
424}
425
426function relaunchDesktopApp(reason: string): void {
427 writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);
428 setImmediate(() => {
429 isQuitting = true;
430 clearUpdatePollTimer();
431 cancelBackendReadinessWait();
432 void stopBackendAndWaitForExit()
433 .catch((error) => {
434 writeDesktopLogHeader(
435 `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
436 );
437 })
438 .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined))
439 .finally(() => {
440 restoreStdIoCapture?.();
441 if (isDevelopment) {
442 app.exit(75);
443 return;
444 }
445 app.relaunch({
446 execPath: process.execPath,
447 args: process.argv.slice(1),
448 });
449 app.exit(0);
450 });
451 });
452}
453
454function writeDesktopLogHeader(message: string): void {
455 if (!desktopLogSink) return;
456 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
457}
458
459function writeBackendSessionBoundary(phase: "START" | "END", details: string): void {
460 if (!backendLogSink) return;
461 const normalizedDetails = sanitizeLogValue(details);
462 backendLogSink.write(
463 `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`,
464 );
465}
466
467function formatErrorMessage(error: unknown): string {
468 if (error instanceof Error) {
469 return error.message;
470 }
471 return String(error);
472}
473
474function getSafeExternalUrl(rawUrl: unknown): string | null {
475 if (typeof rawUrl !== "string" || rawUrl.length === 0) {
476 return null;
477 }
478
479 let parsedUrl: URL;
480 try {
481 parsedUrl = new URL(rawUrl);
482 } catch {
483 return null;
484 }
485
486 if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
487 return null;
488 }
489
490 return parsedUrl.toString();
491}
492
493function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
494 if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") {
495 return rawTheme;
496 }
497
498 return null;
499}
500
501async function waitForBackendHttpReady(
502 baseUrl: string,
503 options?: Parameters<typeof waitForHttpReady>[1],
504): Promise<void> {
505 cancelBackendReadinessWait();
506 const controller = new AbortController();
507 backendReadinessAbortController = controller;
508
509 try {
510 await waitForHttpReady(baseUrl, {
511 ...options,
512 signal: controller.signal,
513 });
514 } finally {
515 if (backendReadinessAbortController === controller) {
516 backendReadinessAbortController = null;
517 }
518 }
519}
520
521function cancelBackendReadinessWait(): void {
522 backendReadinessAbortController?.abort();
523 backendReadinessAbortController = null;
524}
525
526async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
527 return await waitForBackendStartupReady({
528 listeningPromise: backendListeningDetector?.promise ?? null,
529 waitForHttpReady: () =>
530 waitForBackendHttpReady(baseUrl, {
531 timeoutMs: 60_000,
532 }),
533 cancelHttpWait: cancelBackendReadinessWait,
534 });
535}
536
537function ensureInitialBackendWindowOpen(): void {
538 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
539 if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
540 return;
541 }
542
543 const nextOpen = waitForBackendWindowReady(backendHttpUrl)
544 .then((source) => {
545 writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
546 if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
547 return;
548 }
549 mainWindow = createWindow();
550 writeDesktopLogHeader("bootstrap main window created");
551 })
552 .catch((error) => {
553 if (isBackendReadinessAborted(error)) {
554 return;
555 }
556 writeDesktopLogHeader(
557 `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
558 );
559 console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
560 })
561 .finally(() => {
562 if (backendInitialWindowOpenInFlight === nextOpen) {
563 backendInitialWindowOpenInFlight = null;
564 }
565 });
566
567 backendInitialWindowOpenInFlight = nextOpen;
568}
569
570function writeDesktopStreamChunk(
571 streamName: "stdout" | "stderr",
572 chunk: unknown,
573 encoding: BufferEncoding | undefined,
574): void {
575 if (!desktopLogSink) return;
576 const buffer = Buffer.isBuffer(chunk)
577 ? chunk
578 : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined);
579 desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `);
580 desktopLogSink.write(buffer);
581 if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) {
582 desktopLogSink.write("\n");
583 }
584}
585
586function installStdIoCapture(): void {
587 if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) {
588 return;
589 }
590
591 const originalStdoutWrite = process.stdout.write.bind(process.stdout);
592 const originalStderrWrite = process.stderr.write.bind(process.stderr);
593
594 const patchWrite =
595 (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) =>
596 (
597 chunk: string | Uint8Array,
598 encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
599 callback?: (error?: Error | null) => void,
600 ): boolean => {
601 const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;
602 writeDesktopStreamChunk(streamName, chunk, encoding);
603 if (typeof encodingOrCallback === "function") {
604 return originalWrite(chunk, encodingOrCallback);
605 }
606 if (callback !== undefined) {
607 return originalWrite(chunk, encoding, callback);
608 }
609 if (encoding !== undefined) {
610 return originalWrite(chunk, encoding);
611 }
612 return originalWrite(chunk);
613 };
614
615 process.stdout.write = patchWrite("stdout", originalStdoutWrite);
616 process.stderr.write = patchWrite("stderr", originalStderrWrite);
617
618 restoreStdIoCapture = () => {
619 process.stdout.write = originalStdoutWrite;
620 process.stderr.write = originalStderrWrite;
621 restoreStdIoCapture = null;
622 };
623}
624
625function initializePackagedLogging(): void {
626 if (!app.isPackaged) return;
627 try {
628 desktopLogSink = new RotatingFileSink({
629 filePath: Path.join(LOG_DIR, "desktop-main.log"),
630 maxBytes: LOG_FILE_MAX_BYTES,
631 maxFiles: LOG_FILE_MAX_FILES,
632 });
633 backendLogSink = new RotatingFileSink({
634 filePath: Path.join(LOG_DIR, "server-child.log"),
635 maxBytes: LOG_FILE_MAX_BYTES,
636 maxFiles: LOG_FILE_MAX_FILES,
637 });
638 installStdIoCapture();
639 writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`);
640 } catch (error) {
641 // Logging setup should never block app startup.
642 console.error("[desktop] failed to initialize packaged logging", error);
643 }
644}
645
646function captureBackendOutput(child: ChildProcess.ChildProcess): void {
647 const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
648 stream?.on("data", (chunk: unknown) => {
649 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
650 backendLogSink?.write(buffer);
651 backendListeningDetector?.push(buffer);
652 });
653 };
654
655 attachStream(child.stdout);
656 attachStream(child.stderr);
657}
658
659initializePackagedLogging();
660
661if (process.platform === "linux") {
662 app.commandLine.appendSwitch("class", LINUX_WM_CLASS);
663}
664
665function getDestructiveMenuIcon(): Electron.NativeImage | undefined {
666 if (process.platform !== "darwin") return undefined;
667 if (destructiveMenuIconCache !== undefined) {
668 return destructiveMenuIconCache ?? undefined;
669 }
670 try {
671 const icon = nativeImage.createFromNamedImage("trash").resize({
672 width: 14,
673 height: 14,
674 });
675 if (icon.isEmpty()) {
676 destructiveMenuIconCache = null;
677 return undefined;
678 }
679 icon.setTemplateImage(true);
680 destructiveMenuIconCache = icon;
681 return icon;
682 } catch {
683 destructiveMenuIconCache = null;
684 return undefined;
685 }
686}
687let updatePollTimer: ReturnType<typeof setInterval> | null = null;
688let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;
689let updateCheckInFlight = false;
690let updateDownloadInFlight = false;
691let updateInstallInFlight = false;
692let updaterConfigured = false;
693let updateState: DesktopUpdateState = initialUpdateState();
694
695const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({
696 getMainWindow: () => mainWindow,
697 resolveCliRunner: (): RemoteT3RunnerOptions => {
698 if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) {
699 return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH };
700 }
701 return {
702 packageSpec: resolveRemoteT3CliPackageSpec({
703 appVersion: app.getVersion(),
704 updateChannel: desktopSettings.updateChannel,
705 isDevelopment,
706 }),
707 };
708 },
709});
710
711function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
712 if (updateInstallInFlight) return "install";
713 if (updateDownloadInFlight) return "download";
714 if (updateCheckInFlight) return "check";
715 return updateState.errorContext;
716}
717
718protocol.registerSchemesAsPrivileged([
719 {
720 scheme: DESKTOP_SCHEME,
721 privileges: {
722 standard: true,
723 secure: true,
724 supportFetchAPI: true,
725 corsEnabled: true,
726 },
727 },
728]);
729
730function resolveAppRoot(): string {
731 if (!app.isPackaged) {
732 return ROOT_DIR;
733 }
734 return app.getAppPath();
735}
736
737/** Read the baked-in app-update.yml config (if applicable). */
738function readAppUpdateYml(): Record<string, string> | null {
739 try {
740 // electron-updater reads from process.resourcesPath in packaged builds,
741 // or dev-app-update.yml via app.getAppPath() in dev.
742 const ymlPath = app.isPackaged
743 ? Path.join(process.resourcesPath, "app-update.yml")
744 : Path.join(app.getAppPath(), "dev-app-update.yml");
745 const raw = FS.readFileSync(ymlPath, "utf-8");
746 // The YAML is simple key-value pairs — avoid pulling in a YAML parser by
747 // doing a line-based parse (fields: provider, owner, repo, releaseType, …).
748 const entries: Record<string, string> = {};
749 for (const line of raw.split("\n")) {
750 const match = line.match(/^(\w+):\s*(.+)$/);
751 if (match?.[1] && match[2]) entries[match[1]] = match[2].trim();
752 }
753 return entries.provider ? entries : null;
754 } catch {
755 return null;
756 }
757}
758
759function normalizeCommitHash(value: unknown): string | null {
760 if (typeof value !== "string") {
761 return null;
762 }
763 const trimmed = value.trim();
764 if (!COMMIT_HASH_PATTERN.test(trimmed)) {
765 return null;
766 }
767 return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase();
768}
769
770function resolveEmbeddedCommitHash(): string | null {
771 const packageJsonPath = Path.join(resolveAppRoot(), "package.json");
772 if (!FS.existsSync(packageJsonPath)) {
773 return null;
774 }
775
776 try {
777 const raw = FS.readFileSync(packageJsonPath, "utf8");
778 const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown };
779 return normalizeCommitHash(parsed.t3codeCommitHash);
780 } catch {
781 return null;
782 }
783}
784
785function resolveAboutCommitHash(): string | null {
786 if (aboutCommitHashCache !== undefined) {
787 return aboutCommitHashCache;
788 }
789
790 const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH);
791 if (envCommitHash) {
792 aboutCommitHashCache = envCommitHash;
793 return aboutCommitHashCache;
794 }
795
796 // Only packaged builds are required to expose commit metadata.
797 if (!app.isPackaged) {
798 aboutCommitHashCache = null;
799 return aboutCommitHashCache;
800 }
801
802 aboutCommitHashCache = resolveEmbeddedCommitHash();
803
804 return aboutCommitHashCache;
805}
806
807function resolveBackendEntry(): string {
808 return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs");
809}
810
811function resolveBackendCwd(): string {
812 if (!app.isPackaged) {
813 return resolveAppRoot();
814 }
815 return OS.homedir();
816}
817
818function resolveDesktopStaticDir(): string | null {
819 const appRoot = resolveAppRoot();
820 const candidates = [
821 Path.join(appRoot, "apps/server/dist/client"),
822 Path.join(appRoot, "apps/web/dist"),
823 ];
824
825 for (const candidate of candidates) {
826 if (FS.existsSync(Path.join(candidate, "index.html"))) {
827 return candidate;
828 }
829 }
830
831 return null;
832}
833
834function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {
835 const url = new URL(requestUrl);
836 const rawPath = decodeURIComponent(url.pathname);
837 const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, "");
838 if (normalizedPath.includes("..")) {
839 return Path.join(staticRoot, "index.html");
840 }
841
842 const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";
843 const resolvedPath = Path.join(staticRoot, requestedPath);
844
845 if (Path.extname(resolvedPath)) {
846 return resolvedPath;
847 }
848
849 const nestedIndex = Path.join(resolvedPath, "index.html");
850 if (FS.existsSync(nestedIndex)) {
851 return nestedIndex;
852 }
853
854 return Path.join(staticRoot, "index.html");
855}
856
857function isStaticAssetRequest(requestUrl: string): boolean {
858 try {
859 const url = new URL(requestUrl);
860 return Path.extname(url.pathname).length > 0;
861 } catch {
862 return false;
863 }
864}
865
866function handleFatalStartupError(stage: string, error: unknown): void {
867 const message = formatErrorMessage(error);
868 const detail =
869 error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";
870 writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`);
871 console.error(`[desktop] fatal startup error (${stage})`, error);
872 if (!isQuitting) {
873 isQuitting = true;
874 dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`);
875 }
876 stopBackend();
877 restoreStdIoCapture?.();
878 app.quit();
879}
880
881function registerDesktopProtocol(): void {
882 if (isDevelopment || desktopProtocolRegistered) return;
883
884 const staticRoot = resolveDesktopStaticDir();
885 if (!staticRoot) {
886 throw new Error(
887 "Desktop static bundle missing. Build apps/server (with bundled client) first.",
888 );
889 }
890
891 const staticRootResolved = Path.resolve(staticRoot);
892 const staticRootPrefix = `${staticRootResolved}${Path.sep}`;
893 const fallbackIndex = Path.join(staticRootResolved, "index.html");
894
895 protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {
896 try {
897 const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);
898 const resolvedCandidate = Path.resolve(candidate);
899 const isInRoot =
900 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
901 const isAssetRequest = isStaticAssetRequest(request.url);
902
903 if (!isInRoot || !FS.existsSync(resolvedCandidate)) {
904 if (isAssetRequest) {
905 callback({ error: -6 });
906 return;
907 }
908 callback({ path: fallbackIndex });
909 return;
910 }
911
912 callback({ path: resolvedCandidate });
913 } catch {
914 callback({ path: fallbackIndex });
915 }
916 });
917
918 desktopProtocolRegistered = true;
919}
920
921function dispatchMenuAction(action: string): void {
922 const existingWindow =
923 BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
924 const targetWindow = existingWindow ?? createWindow();
925 if (!existingWindow) {
926 mainWindow = targetWindow;
927 }
928
929 const send = () => {
930 if (targetWindow.isDestroyed()) return;
931 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
932 revealWindow(targetWindow);
933 };
934
935 if (targetWindow.webContents.isLoadingMainFrame()) {
936 targetWindow.webContents.once("did-finish-load", send);
937 return;
938 }
939
940 send();
941}
942
943function handleCheckForUpdatesMenuClick(): void {
944 const hasUpdateFeedConfig =
945 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
946 const disabledReason = getAutoUpdateDisabledReason({
947 isDevelopment,
948 isPackaged: app.isPackaged,
949 platform: process.platform,
950 appImage: process.env.APPIMAGE,
951 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
952 hasUpdateFeedConfig,
953 });
954 if (disabledReason) {
955 console.info("[desktop-updater] Manual update check requested, but updates are disabled.");
956 void dialog.showMessageBox({
957 type: "info",
958 title: "Updates unavailable",
959 message: "Automatic updates are not available right now.",
960 detail: disabledReason,
961 buttons: ["OK"],
962 });
963 return;
964 }
965
966 if (!BrowserWindow.getAllWindows().length) {
967 mainWindow = createWindow();
968 }
969 void checkForUpdatesFromMenu();
970}
971
972async function checkForUpdatesFromMenu(): Promise<void> {
973 await checkForUpdates("menu");
974
975 if (updateState.status === "up-to-date") {
976 void dialog.showMessageBox({
977 type: "info",
978 title: "You're up to date!",
979 message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
980 buttons: ["OK"],
981 });
982 } else if (updateState.status === "error") {
983 void dialog.showMessageBox({
984 type: "warning",
985 title: "Update check failed",
986 message: "Could not check for updates.",
987 detail: updateState.message ?? "An unknown error occurred. Please try again later.",
988 buttons: ["OK"],
989 });
990 }
991}
992
993function configureApplicationMenu(): void {
994 const template: MenuItemConstructorOptions[] = [];
995
996 if (process.platform === "darwin") {
997 template.push({
998 label: app.name,
999 submenu: [
1000 { role: "about" },
1001 {
1002 label: "Check for Updates...",
1003 click: () => handleCheckForUpdatesMenuClick(),
1004 },
1005 { type: "separator" },
1006 {
1007 label: "Settings...",
1008 accelerator: "CmdOrCtrl+,",
1009 click: () => dispatchMenuAction("open-settings"),
1010 },
1011 { type: "separator" },
1012 { role: "services" },
1013 { type: "separator" },
1014 { role: "hide" },
1015 { role: "hideOthers" },
1016 { role: "unhide" },
1017 { type: "separator" },
1018 { role: "quit" },
1019 ],
1020 });
1021 }
1022
1023 template.push(
1024 {
1025 label: "File",
1026 submenu: [
1027 ...(process.platform === "darwin"
1028 ? []
1029 : [
1030 {
1031 label: "Settings...",
1032 accelerator: "CmdOrCtrl+,",
1033 click: () => dispatchMenuAction("open-settings"),
1034 },
1035 { type: "separator" as const },
1036 ]),
1037 { role: process.platform === "darwin" ? "close" : "quit" },
1038 ],
1039 },
1040 { role: "editMenu" },
1041 {
1042 label: "View",
1043 submenu: [
1044 { role: "reload" },
1045 { role: "forceReload" },
1046 { role: "toggleDevTools" },
1047 { type: "separator" },
1048 { role: "resetZoom" },
1049 { role: "zoomIn", accelerator: "CmdOrCtrl+=" },
1050 { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
1051 { role: "zoomOut" },
1052 { type: "separator" },
1053 { role: "togglefullscreen" },
1054 ],
1055 },
1056 { role: "windowMenu" },
1057 {
1058 role: "help",
1059 submenu: [
1060 {
1061 label: "Check for Updates...",
1062 click: () => handleCheckForUpdatesMenuClick(),
1063 },
1064 ],
1065 },
1066 );
1067
1068 Menu.setApplicationMenu(Menu.buildFromTemplate(template));
1069}
1070
1071function resolveResourcePath(fileName: string): string | null {
1072 const candidates = [
1073 Path.join(__dirname, "../resources", fileName),
1074 Path.join(__dirname, "../prod-resources", fileName),
1075 Path.join(process.resourcesPath, "resources", fileName),
1076 Path.join(process.resourcesPath, fileName),
1077 ];
1078
1079 for (const candidate of candidates) {
1080 if (FS.existsSync(candidate)) {
1081 return candidate;
1082 }
1083 }
1084
1085 return null;
1086}
1087
1088function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
1089 if (isDevelopment && process.platform === "darwin" && ext === "png") {
1090 const developmentDockIconPath = Path.join(
1091 ROOT_DIR,
1092 "assets",
1093 "dev",
1094 "blueprint-macos-1024.png",
1095 );51 );
1096 if (FS.existsSync(developmentDockIconPath)) {52 return DesktopEnvironment.layer({
1097 return developmentDockIconPath;53 dirname: __dirname,
1098 }54 homeDirectory: NodeOS.homedir(),
1099 }
1100
1101 return resolveResourcePath(`icon.${ext}`);
1102}
1103
1104/**
1105 * Resolve the Electron userData directory path.
1106 *
1107 * Electron derives the default userData path from `productName` in
1108 * package.json, which currently produces directories with spaces and
1109 * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is
1110 * unfriendly for shell usage and violates Linux naming conventions.
1111 *
1112 * We override it to a clean lowercase name (`t3code`). If the legacy
1113 * directory already exists we keep using it so existing users don't
1114 * lose their Chromium profile data (localStorage, cookies, sessions).
1115 */
1116function resolveUserDataPath(): string {
1117 const appDataBase =
1118 process.platform === "win32"
1119 ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming")
1120 : process.platform === "darwin"
1121 ? Path.join(OS.homedir(), "Library", "Application Support")
1122 : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config");
1123
1124 const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME);
1125 if (FS.existsSync(legacyPath)) {
1126 return legacyPath;
1127 }
1128
1129 return Path.join(appDataBase, USER_DATA_DIR_NAME);
1130}
1131
1132function configureAppIdentity(): void {
1133 app.setName(APP_DISPLAY_NAME);
1134 const commitHash = resolveAboutCommitHash();
1135 app.setAboutPanelOptions({
1136 applicationName: APP_DISPLAY_NAME,
1137 applicationVersion: app.getVersion(),
1138 version: commitHash ?? "unknown",
1139 });
1140
1141 if (process.platform === "win32") {
1142 app.setAppUserModelId(APP_USER_MODEL_ID);
1143 }
1144
1145 if (process.platform === "linux") {
1146 (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME);
1147 }
1148
1149 if (process.platform === "darwin" && app.dock) {
1150 const iconPath = resolveIconPath("png");
1151 if (iconPath) {
1152 app.dock.setIcon(iconPath);
1153 }
1154 }
1155}
1156
1157function clearUpdatePollTimer(): void {
1158 if (updateStartupTimer) {
1159 clearTimeout(updateStartupTimer);
1160 updateStartupTimer = null;
1161 }
1162 if (updatePollTimer) {
1163 clearInterval(updatePollTimer);
1164 updatePollTimer = null;
1165 }
1166}
1167
1168function revealWindow(window: BrowserWindow): void {
1169 if (window.isDestroyed()) {
1170 return;
1171 }
1172
1173 if (window.isMinimized()) {
1174 window.restore();
1175 }
1176
1177 if (!window.isVisible()) {
1178 window.show();
1179 }
1180
1181 if (process.platform === "darwin") {
1182 app.focus({ steal: true });
1183 }
1184
1185 window.focus();
1186}
1187
1188function emitUpdateState(): void {
1189 for (const window of BrowserWindow.getAllWindows()) {
1190 if (window.isDestroyed()) continue;
1191 window.webContents.send(UPDATE_STATE_CHANNEL, updateState);
1192 }
1193}
1194
1195function setUpdateState(patch: Partial<DesktopUpdateState>): void {
1196 updateState = { ...updateState, ...patch };
1197 emitUpdateState();
1198}
1199
1200function createBaseUpdateState(
1201 channel: DesktopUpdateChannel,
1202 enabled: boolean,
1203): DesktopUpdateState {
1204 return {
1205 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel),
1206 enabled,
1207 status: enabled ? "idle" : "disabled",
1208 };
1209}
1210
1211function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
1212 autoUpdater.channel = channel;
1213 autoUpdater.allowPrerelease = channel === "nightly";
1214 autoUpdater.allowDowngrade = channel === "nightly";
1215 console.info(
1216 `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`,
1217 );
1218}
1219
1220function shouldEnableAutoUpdates(): boolean {
1221 const hasUpdateFeedConfig =
1222 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
1223 return (
1224 getAutoUpdateDisabledReason({
1225 isDevelopment,
1226 isPackaged: app.isPackaged,
1227 platform: process.platform,55 platform: process.platform,
1228 appImage: process.env.APPIMAGE,56 processArch: process.arch,
1229 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",57 ...metadata,
1230 hasUpdateFeedConfig,
1231 }) === null
1232 );
1233}
1234
1235async function checkForUpdates(reason: string): Promise<boolean> {
1236 if (isQuitting || !updaterConfigured || updateCheckInFlight) return false;
1237 if (updateState.status === "downloading" || updateState.status === "downloaded") {
1238 console.info(
1239 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
1240 );
1241 return false;
1242 }
1243 updateCheckInFlight = true;
1244 setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));
1245 console.info(`[desktop-updater] Checking for updates (${reason})...`);
1246
1247 try {
1248 await autoUpdater.checkForUpdates();
1249 return true;
1250 } catch (error: unknown) {
1251 const message = error instanceof Error ? error.message : String(error);
1252 setUpdateState(
1253 reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
1254 );
1255 console.error(`[desktop-updater] Failed to check for updates: ${message}`);
1256 return true;
1257 } finally {
1258 updateCheckInFlight = false;
1259 }
1260}
1261
1262async function downloadAvailableUpdate(): Promise<{
1263 accepted: boolean;
1264 completed: boolean;
1265}> {
1266 if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") {
1267 return { accepted: false, completed: false };
1268 }
1269 updateDownloadInFlight = true;
1270 setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));
1271 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
1272 console.info("[desktop-updater] Downloading update...");
1273
1274 try {
1275 await autoUpdater.downloadUpdate();
1276 return { accepted: true, completed: true };
1277 } catch (error: unknown) {
1278 const message = error instanceof Error ? error.message : String(error);
1279 setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message));
1280 console.error(`[desktop-updater] Failed to download update: ${message}`);
1281 return { accepted: true, completed: false };
1282 } finally {
1283 updateDownloadInFlight = false;
1284 }
1285}
1286
1287async function installDownloadedUpdate(): Promise<{
1288 accepted: boolean;
1289 completed: boolean;
1290}> {
1291 if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") {
1292 return { accepted: false, completed: false };
1293 }
1294
1295 isQuitting = true;
1296 updateInstallInFlight = true;
1297 clearUpdatePollTimer();
1298 try {
1299 await stopBackendAndWaitForExit();
1300 // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close.
1301 for (const win of BrowserWindow.getAllWindows()) {
1302 win.destroy();
1303 }
1304 // `quitAndInstall()` only starts the handoff to the updater. The actual
1305 // install may still fail asynchronously, so keep the action incomplete
1306 // until we either quit or receive an updater error.
1307 autoUpdater.quitAndInstall(true, true);
1308 return { accepted: true, completed: false };
1309 } catch (error: unknown) {
1310 const message = formatErrorMessage(error);
1311 updateInstallInFlight = false;
1312 isQuitting = false;
1313 setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
1314 console.error(`[desktop-updater] Failed to install update: ${message}`);
1315 return { accepted: true, completed: false };
1316 }
1317}
1318
1319function configureAutoUpdater(): void {
1320 const githubToken =
1321 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";
1322 if (githubToken) {
1323 // When a token is provided, re-configure the feed with `private: true` so
1324 // electron-updater uses the GitHub API (api.github.com) instead of the
1325 // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth.
1326 const appUpdateYml = readAppUpdateYml();
1327 if (appUpdateYml?.provider === "github") {
1328 autoUpdater.setFeedURL({
1329 ...appUpdateYml,
1330 provider: "github" as const,
1331 private: true,
1332 token: githubToken,
1333 });
1334 }
1335 }
1336
1337 if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) {
1338 autoUpdater.setFeedURL({
1339 provider: "generic",
1340 url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`,
1341 });58 });
1342 }59 }),
134360);
1344 const enabled = shouldEnableAutoUpdates();
1345 setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled));
1346 if (!enabled) {
1347 return;
1348 }
1349 updaterConfigured = true;
1350
1351 autoUpdater.autoDownload = false;
1352 autoUpdater.autoInstallOnAppQuit = false;
1353 applyAutoUpdaterChannel(desktopSettings.updateChannel);
1354 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
1355 let lastLoggedDownloadMilestone = -1;
1356
1357 if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) {
1358 console.info(
1359 "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.",
1360 );
1361 }
1362
1363 autoUpdater.on("checking-for-update", () => {
1364 console.info("[desktop-updater] Looking for updates...");
1365 });
1366 autoUpdater.on("update-available", (info) => {
1367 if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) {
1368 console.info(
1369 `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`,
1370 );
1371 setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1372 lastLoggedDownloadMilestone = -1;
1373 return;
1374 }
1375
1376 setUpdateState(
1377 reduceDesktopUpdateStateOnUpdateAvailable(
1378 updateState,
1379 info.version,
1380 new Date().toISOString(),
1381 ),
1382 );
1383 lastLoggedDownloadMilestone = -1;
1384 console.info(`[desktop-updater] Update available: ${info.version}`);
1385 });
1386 autoUpdater.on("update-not-available", () => {
1387 setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1388 lastLoggedDownloadMilestone = -1;
1389 console.info("[desktop-updater] No updates available.");
1390 });
1391 autoUpdater.on("error", (error) => {
1392 const message = formatErrorMessage(error);
1393 if (updateInstallInFlight) {
1394 updateInstallInFlight = false;
1395 isQuitting = false;
1396 setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
1397 console.error(`[desktop-updater] Updater error: ${message}`);
1398 return;
1399 }
1400 if (!updateCheckInFlight && !updateDownloadInFlight) {
1401 setUpdateState({
1402 status: "error",
1403 message,
1404 checkedAt: new Date().toISOString(),
1405 downloadPercent: null,
1406 errorContext: resolveUpdaterErrorContext(),
1407 canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null,
1408 });
1409 }
1410 console.error(`[desktop-updater] Updater error: ${message}`);
1411 });
1412 autoUpdater.on("download-progress", (progress) => {
1413 const percent = Math.floor(progress.percent);
1414 if (
1415 shouldBroadcastDownloadProgress(updateState, progress.percent) ||
1416 updateState.message !== null
1417 ) {
1418 setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent));
1419 }
1420 const milestone = percent - (percent % 10);
1421 if (milestone > lastLoggedDownloadMilestone) {
1422 lastLoggedDownloadMilestone = milestone;
1423 console.info(`[desktop-updater] Download progress: ${percent}%`);
1424 }
1425 });
1426 autoUpdater.on("update-downloaded", (info) => {
1427 setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version));
1428 console.info(`[desktop-updater] Update downloaded: ${info.version}`);
1429 });
1430
1431 clearUpdatePollTimer();
143261
1433 updateStartupTimer = setTimeout(() => {62const resolveDesktopSshCliRunner = (
1434 updateStartupTimer = null;63 environment: DesktopEnvironment.DesktopEnvironmentShape,
1435 void checkForUpdates("startup");64 settings: DesktopSettingsValue,
1436 }, AUTO_UPDATE_STARTUP_DELAY_MS);65): RemoteT3RunnerOptions => {
1437 updateStartupTimer.unref();66 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
143867 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
1439 updatePollTimer = setInterval(() => {68 return { nodeScriptPath: devRemoteEntryPath };
1440 void checkForUpdates("poll");
1441 }, AUTO_UPDATE_POLL_INTERVAL_MS);
1442 updatePollTimer.unref();
1443}
1444function scheduleBackendRestart(reason: string): void {
1445 if (isQuitting || restartTimer) return;
1446
1447 const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000);
1448 restartAttempt += 1;
1449 console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`);
1450
1451 restartTimer = setTimeout(() => {
1452 restartTimer = null;
1453 startBackend();
1454 }, delayMs);
1455}
1456
1457function startBackend(): void {
1458 if (isQuitting || backendProcess) return;
1459
1460 backendObservabilitySettings = readPersistedBackendObservabilitySettings();
1461 const backendEntry = resolveBackendEntry();
1462 if (!FS.existsSync(backendEntry)) {
1463 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
1464 return;
1465 }
1466
1467 const captureBackendLogs = !isDevelopment;
1468 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
1469 cwd: resolveBackendCwd(),
1470 // In Electron main, process.execPath points to the Electron binary.
1471 // Run the child in Node mode so this backend process does not become a GUI app instance.
1472 env: {
1473 ...backendChildEnv(),
1474 ELECTRON_RUN_AS_NODE: "1",
1475 },
1476 stdio: captureBackendLogs
1477 ? ["ignore", "pipe", "pipe", "pipe"]
1478 : ["ignore", "inherit", "inherit", "pipe"],
1479 });
1480 const bootstrapStream = child.stdio[3];
1481 if (bootstrapStream && "write" in bootstrapStream) {
1482 bootstrapStream.write(
1483 `${JSON.stringify({
1484 mode: "desktop",
1485 noBrowser: true,
1486 port: backendPort,
1487 t3Home: BASE_DIR,
1488 host: backendBindHost,
1489 desktopBootstrapToken: backendBootstrapToken,
1490 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
1491 tailscaleServePort: desktopSettings.tailscaleServePort,
1492 ...(backendObservabilitySettings.otlpTracesUrl
1493 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
1494 : {}),
1495 ...(backendObservabilitySettings.otlpMetricsUrl
1496 ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl }
1497 : {}),
1498 })}\n`,
1499 );
1500 bootstrapStream.end();
1501 } else {
1502 child.kill("SIGTERM");
1503 scheduleBackendRestart("missing desktop bootstrap pipe");
1504 return;
1505 }69 }
1506 const listeningDetector = new ServerListeningDetector();
1507 backendListeningDetector = listeningDetector;
1508 backendProcess = child;
1509 let backendSessionClosed = false;
1510 const closeBackendSession = (details: string) => {
1511 if (backendSessionClosed) return;
1512 backendSessionClosed = true;
1513 writeBackendSessionBoundary("END", details);
1514 };
1515 writeBackendSessionBoundary(
1516 "START",
1517 `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`,
1518 );
1519 captureBackendOutput(child);
1520
1521 child.once("spawn", () => {
1522 restartAttempt = 0;
1523 });
1524
1525 child.on("error", (error) => {
1526 if (backendListeningDetector === listeningDetector) {
1527 listeningDetector.fail(error);
1528 backendListeningDetector = null;
1529 }
1530 const wasExpected = expectedBackendExitChildren.has(child);
1531 if (backendProcess === child) {
1532 backendProcess = null;
1533 }
1534 closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
1535 if (wasExpected) {
1536 return;
1537 }
1538 scheduleBackendRestart(error.message);
1539 });
1540
1541 child.on("exit", (code, signal) => {
1542 if (backendListeningDetector === listeningDetector) {
1543 listeningDetector.fail(
1544 new Error(
1545 `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
1546 ),
1547 );
1548 backendListeningDetector = null;
1549 }
1550 const wasExpected = expectedBackendExitChildren.has(child);
1551 if (backendProcess === child) {
1552 backendProcess = null;
1553 }
1554 closeBackendSession(
1555 `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
1556 );
1557 if (isQuitting || wasExpected) return;
1558 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
1559 scheduleBackendRestart(reason);
1560 });
1561
1562 ensureInitialBackendWindowOpen();
1563}
1564
1565function stopBackend(): void {
1566 cancelBackendReadinessWait();
1567 backendListeningDetector = null;
1568 if (restartTimer) {
1569 clearTimeout(restartTimer);
1570 restartTimer = null;
1571 }
1572
1573 const child = backendProcess;
1574 backendProcess = null;
1575 if (!child) return;
1576
1577 if (child.exitCode === null && child.signalCode === null) {
1578 expectedBackendExitChildren.add(child);
1579 child.kill("SIGTERM");
1580 setTimeout(() => {
1581 if (child.exitCode === null && child.signalCode === null) {
1582 child.kill("SIGKILL");
1583 }
1584 }, 2_000).unref();
1585 }
1586}
1587
1588async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1589 cancelBackendReadinessWait();
1590 if (restartTimer) {
1591 clearTimeout(restartTimer);
1592 restartTimer = null;
1593 }
1594
1595 const child = backendProcess;
1596 backendProcess = null;
1597 if (!child) return;
1598 const backendChild = child;
1599 if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;
1600 expectedBackendExitChildren.add(backendChild);
1601
1602 await new Promise<void>((resolve) => {
1603 let settled = false;
1604 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
1605 let exitTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
1606
1607 function settle(): void {
1608 if (settled) return;
1609 settled = true;
1610 backendChild.off("exit", onExit);
1611 if (forceKillTimer) {
1612 clearTimeout(forceKillTimer);
1613 }
1614 if (exitTimeoutTimer) {
1615 clearTimeout(exitTimeoutTimer);
1616 }
1617 resolve();
1618 }
1619
1620 function onExit(): void {
1621 settle();
1622 }
1623
1624 backendChild.once("exit", onExit);
1625 backendChild.kill("SIGTERM");
1626
1627 forceKillTimer = setTimeout(() => {
1628 if (backendChild.exitCode === null && backendChild.signalCode === null) {
1629 backendChild.kill("SIGKILL");
1630 }
1631 }, 2_000);
1632 forceKillTimer.unref();
1633
1634 exitTimeoutTimer = setTimeout(() => {
1635 settle();
1636 }, timeoutMs);
1637 exitTimeoutTimer.unref();
1638 });
1639}
1640
1641function registerIpcHandlers(): void {
1642 ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL);
1643 ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => {
1644 event.returnValue = desktopAppBranding;
1645 });
1646
1647 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1648 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1649 event.returnValue = {
1650 label: "Local environment",
1651 httpBaseUrl: backendHttpUrl || null,
1652 wsBaseUrl: backendWsUrl || null,
1653 bootstrapToken: backendBootstrapToken || undefined,
1654 } as const;
1655 });
1656
1657 ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL);
1658 ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH));
1659
1660 ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL);
1661 ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => {
1662 if (typeof rawSettings !== "object" || rawSettings === null) {
1663 throw new Error("Invalid client settings payload.");
1664 }
1665
1666 writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings);
1667 });
1668
1669 ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1670 ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () =>
1671 readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH),
1672 );
1673
1674 ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1675 ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => {
1676 if (!Array.isArray(rawRecords)) {
1677 throw new Error("Invalid saved environment registry payload.");
1678 }
1679
1680 writeSavedEnvironmentRegistry(
1681 SAVED_ENVIRONMENT_REGISTRY_PATH,
1682 rawRecords as readonly PersistedSavedEnvironmentRecord[],
1683 );
1684 });
1685
1686 ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1687 ipcMain.handle(
1688 GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1689 async (_event, rawEnvironmentId: unknown) => {
1690 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1691 return null;
1692 }
1693
1694 return readSavedEnvironmentSecret({
1695 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1696 environmentId: rawEnvironmentId,
1697 secretStorage: getDesktopSecretStorage(),
1698 });
1699 },
1700 );
1701
1702 ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1703 ipcMain.handle(
1704 SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1705 async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => {
1706 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1707 throw new Error("Invalid saved environment id.");
1708 }
1709 if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) {
1710 throw new Error("Invalid saved environment secret.");
1711 }
1712
1713 return writeSavedEnvironmentSecret({
1714 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1715 environmentId: rawEnvironmentId,
1716 secret: rawSecret,
1717 secretStorage: getDesktopSecretStorage(),
1718 });
1719 },
1720 );
1721
1722 ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1723 ipcMain.handle(
1724 REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1725 async (_event, rawEnvironmentId: unknown) => {
1726 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1727 return;
1728 }
1729
1730 removeSavedEnvironmentSecret({
1731 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1732 environmentId: rawEnvironmentId,
1733 });
1734 },
1735 );
1736
1737 desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain);
1738
1739 ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
1740 ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
1741
1742 ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL);
1743 ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => {
1744 if (rawMode !== "local-only" && rawMode !== "network-accessible") {
1745 throw new Error("Invalid desktop server exposure input.");
1746 }
1747
1748 const nextMode = rawMode as DesktopServerExposureMode;
1749 if (nextMode === desktopServerExposureMode) {
1750 return getDesktopServerExposureState();
1751 }
1752
1753 const nextState = await applyDesktopServerExposureMode(nextMode, {
1754 persist: true,
1755 rejectIfUnavailable: true,
1756 });
1757 relaunchDesktopApp(`serverExposureMode=${nextMode}`);
1758 return nextState;
1759 });
1760
1761 ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL);
1762 ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => {
1763 if (typeof rawInput !== "object" || rawInput === null) {
1764 throw new Error("Invalid Tailscale Serve input.");
1765 }
1766 const input = rawInput as {
1767 readonly enabled?: unknown;
1768 readonly port?: unknown;
1769 };
1770 if (typeof input.enabled !== "boolean") {
1771 throw new Error("Invalid Tailscale Serve input.");
1772 }
1773 const nextSettings = setDesktopTailscaleServePreference(desktopSettings, {
1774 enabled: input.enabled,
1775 ...(typeof input.port === "number" ? { port: input.port } : {}),
1776 });
1777 if (nextSettings === desktopSettings) {
1778 return getDesktopServerExposureState();
1779 }
1780 return applyDesktopTailscaleServeEnabled(nextSettings);
1781 });
1782
1783 ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL);
1784 ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints());
1785
1786 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1787 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
1788 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1789 const defaultPath = resolvePickFolderDefaultPath(rawOptions);
1790 const openDialogOptions: OpenDialogOptions = {
1791 properties: ["openDirectory", "createDirectory"],
1792 ...(defaultPath ? { defaultPath } : {}),
1793 };
1794 const result = owner
1795 ? await dialog.showOpenDialog(owner, openDialogOptions)
1796 : await dialog.showOpenDialog(openDialogOptions);
1797 if (result.canceled) return null;
1798 return result.filePaths[0] ?? null;
1799 });
1800
1801 ipcMain.removeHandler(CONFIRM_CHANNEL);
1802 ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => {
1803 if (typeof message !== "string") {
1804 return false;
1805 }
1806
1807 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1808 return showDesktopConfirmDialog(message, owner);
1809 });
1810
1811 ipcMain.removeHandler(SET_THEME_CHANNEL);
1812 ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => {
1813 const theme = getSafeTheme(rawTheme);
1814 if (!theme) {
1815 return;
1816 }
1817
1818 nativeTheme.themeSource = theme;
1819 });
1820
1821 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
1822 ipcMain.handle(
1823 CONTEXT_MENU_CHANNEL,
1824 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1825 const normalizedItems = normalizeContextMenuItems(items);
1826 if (normalizedItems.length === 0) {
1827 return null;
1828 }
1829
1830 const popupPosition =
1831 position &&
1832 Number.isFinite(position.x) &&
1833 Number.isFinite(position.y) &&
1834 position.x >= 0 &&
1835 position.y >= 0
1836 ? {
1837 x: Math.floor(position.x),
1838 y: Math.floor(position.y),
1839 }
1840 : null;
1841
1842 const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
1843 if (!window) return null;
1844
1845 return new Promise<string | null>((resolve) => {
1846 const buildTemplate = (
1847 entries: readonly ContextMenuItem[],
1848 ): MenuItemConstructorOptions[] => {
1849 const template: MenuItemConstructorOptions[] = [];
1850 let hasInsertedDestructiveSeparator = false;
1851 for (const item of entries) {
1852 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1853 template.push({ type: "separator" });
1854 hasInsertedDestructiveSeparator = true;
1855 }
1856 const itemOption: MenuItemConstructorOptions = {
1857 label: item.label,
1858 enabled: !item.disabled,
1859 };
1860 if (item.children && item.children.length > 0) {
1861 itemOption.submenu = buildTemplate(item.children);
1862 } else {
1863 itemOption.click = () => resolve(item.id);
1864 }
1865 if (item.destructive && (!item.children || item.children.length === 0)) {
1866 const destructiveIcon = getDestructiveMenuIcon();
1867 if (destructiveIcon) {
1868 itemOption.icon = destructiveIcon;
1869 }
1870 }
1871 template.push(itemOption);
1872 }
1873 return template;
1874 };
1875
1876 const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
1877 menu.popup({
1878 window,
1879 ...popupPosition,
1880 callback: () => resolve(null),
1881 });
1882 });
1883 },
1884 );
1885
1886 ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
1887 ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
1888 const externalUrl = getSafeExternalUrl(rawUrl);
1889 if (!externalUrl) {
1890 return false;
1891 }
1892
1893 try {
1894 await shell.openExternal(externalUrl);
1895 return true;
1896 } catch {
1897 return false;
1898 }
1899 });
1900
1901 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
1902 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
1903
1904 ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL);
1905 ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => {
1906 if (rawChannel !== "latest" && rawChannel !== "nightly") {
1907 throw new Error("Invalid desktop update channel input.");
1908 }
1909 if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) {
1910 throw new Error("Cannot change update tracks while an update action is in progress.");
1911 }
1912
1913 const nextChannel = rawChannel as DesktopUpdateChannel;
1914
1915 desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
1916 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
1917
1918 if (nextChannel === updateState.channel) {
1919 return updateState;
1920 }
1921
1922 const enabled = shouldEnableAutoUpdates();
1923 setUpdateState(createBaseUpdateState(nextChannel, enabled));
1924
1925 if (!enabled || !updaterConfigured) {
1926 return updateState;
1927 }
1928
1929 applyAutoUpdaterChannel(nextChannel);
1930 const allowDowngrade = autoUpdater.allowDowngrade;
1931 // An explicit channel switch should allow the immediate nightly->stable rollback path.
1932 autoUpdater.allowDowngrade = true;
1933 try {
1934 await checkForUpdates("channel-change");
1935 } finally {
1936 autoUpdater.allowDowngrade = allowDowngrade;
1937 }
1938 return updateState;
1939 });
1940
1941 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
1942 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {
1943 const result = await downloadAvailableUpdate();
1944 return {
1945 accepted: result.accepted,
1946 completed: result.completed,
1947 state: updateState,
1948 } satisfies DesktopUpdateActionResult;
1949 });
1950
1951 ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL);
1952 ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => {
1953 if (isQuitting) {
1954 return {
1955 accepted: false,
1956 completed: false,
1957 state: updateState,
1958 } satisfies DesktopUpdateActionResult;
1959 }
1960 const result = await installDownloadedUpdate();
1961 return {
1962 accepted: result.accepted,
1963 completed: result.completed,
1964 state: updateState,
1965 } satisfies DesktopUpdateActionResult;
1966 });
1967
1968 ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
1969 ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => {
1970 if (!updaterConfigured) {
1971 return {
1972 checked: false,
1973 state: updateState,
1974 } satisfies DesktopUpdateCheckResult;
1975 }
1976 const checked = await checkForUpdates("web-ui");
1977 return {
1978 checked,
1979 state: updateState,
1980 } satisfies DesktopUpdateCheckResult;
1981 });
1982}
1983
1984function getIconOption(): { icon: string } | Record<string, never> {
1985 if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle
1986 const ext = process.platform === "win32" ? "ico" : "png";
1987 const iconPath = resolveIconPath(ext);
1988 return iconPath ? { icon: iconPath } : {};
1989}
1990
1991function getInitialWindowBackgroundColor(): string {
1992 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1993}
1994
1995function getWindowTitleBarOptions(): WindowTitleBarOptions {
1996 if (process.platform === "darwin") {
1997 return {
1998 titleBarStyle: "hiddenInset",
1999 trafficLightPosition: { x: 16, y: 18 },
2000 };
2001 }
2002
2003 return {70 return {
2004 titleBarStyle: "hidden",71 packageSpec: resolveRemoteT3CliPackageSpec({
2005 titleBarOverlay: {72 appVersion: environment.appVersion,
2006 color: TITLEBAR_COLOR,73 updateChannel: settings.updateChannel,
2007 height: TITLEBAR_HEIGHT,74 isDevelopment: environment.isDevelopment,
2008 symbolColor: nativeTheme.shouldUseDarkColors75 }),
2009 ? TITLEBAR_DARK_SYMBOL_COLOR
2010 : TITLEBAR_LIGHT_SYMBOL_COLOR,
2011 },
2012 };76 };
2013}77};
2014
2015function syncWindowAppearance(window: BrowserWindow): void {
2016 if (window.isDestroyed()) {
2017 return;
2018 }
2019
2020 window.setBackgroundColor(getInitialWindowBackgroundColor());
2021 const { titleBarOverlay } = getWindowTitleBarOptions();
2022 if (typeof titleBarOverlay === "object") {
2023 window.setTitleBarOverlay(titleBarOverlay);
2024 }
2025}
2026
2027function syncAllWindowAppearance(): void {
2028 for (const window of BrowserWindow.getAllWindows()) {
2029 syncWindowAppearance(window);
2030 }
2031}
2032
2033nativeTheme.on("updated", syncAllWindowAppearance);
2034
2035function createWindow(): BrowserWindow {
2036 const window = new BrowserWindow({
2037 width: 1100,
2038 height: 780,
2039 minWidth: 840,
2040 minHeight: 620,
2041 show: false,
2042 autoHideMenuBar: true,
2043 backgroundColor: getInitialWindowBackgroundColor(),
2044 ...getIconOption(),
2045 title: APP_DISPLAY_NAME,
2046 ...getWindowTitleBarOptions(),
2047 webPreferences: {
2048 preload: Path.join(__dirname, "preload.cjs"),
2049 contextIsolation: true,
2050 nodeIntegration: false,
2051 sandbox: true,
2052 },
2053 });
2054
2055 window.webContents.on("context-menu", (event, params) => {
2056 event.preventDefault();
2057
2058 const menuTemplate: MenuItemConstructorOptions[] = [];
2059
2060 if (params.misspelledWord) {
2061 for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
2062 menuTemplate.push({
2063 label: suggestion,
2064 click: () => window.webContents.replaceMisspelling(suggestion),
2065 });
2066 }
2067 if (params.dictionarySuggestions.length === 0) {
2068 menuTemplate.push({ label: "No suggestions", enabled: false });
2069 }
2070 menuTemplate.push({ type: "separator" });
2071 }
2072
2073 const externalUrl = getSafeExternalUrl(params.linkURL);
2074 if (externalUrl) {
2075 menuTemplate.push(
2076 {
2077 label: "Copy Link",
2078 click: () => clipboard.writeText(params.linkURL),
2079 },
2080 { type: "separator" },
2081 );
2082 }
2083
2084 if (params.mediaType === "image") {
2085 menuTemplate.push({
2086 label: "Copy Image",
2087 click: () => window.webContents.copyImageAt(params.x, params.y),
2088 });
2089 menuTemplate.push({ type: "separator" });
2090 }
2091
2092 menuTemplate.push(
2093 { role: "cut", enabled: params.editFlags.canCut },
2094 { role: "copy", enabled: params.editFlags.canCopy },
2095 { role: "paste", enabled: params.editFlags.canPaste },
2096 { role: "selectAll", enabled: params.editFlags.canSelectAll },
2097 );
2098
2099 Menu.buildFromTemplate(menuTemplate).popup({ window });
2100 });
2101
2102 window.webContents.setWindowOpenHandler(({ url }) => {
2103 const externalUrl = getSafeExternalUrl(url);
2104 if (externalUrl) {
2105 void shell.openExternal(externalUrl);
2106 }
2107 return { action: "deny" };
2108 });
2109
2110 window.on("page-title-updated", (event) => {
2111 event.preventDefault();
2112 window.setTitle(APP_DISPLAY_NAME);
2113 });
2114 window.webContents.on("did-finish-load", () => {
2115 window.setTitle(APP_DISPLAY_NAME);
2116 emitUpdateState();
2117 });
2118
2119 // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only
2120 // fires after `show()` is called, deadlocking the standard "wait for
2121 // ready, then show" pattern. Add `did-finish-load` as a Linux-only
2122 // fallback so the window still surfaces once the renderer has loaded
2123 // the page. Other platforms keep the no-flash `ready-to-show` path,
2124 // since `did-finish-load` typically fires before the first paint there.
2125 const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
2126 if (process.platform === "linux") {
2127 revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
2128 }
2129 bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window));
2130
2131 if (isDevelopment) {
2132 void window.loadURL(resolveDesktopDevServerUrl());
2133 window.webContents.openDevTools({ mode: "detach" });
2134 } else {
2135 void window.loadURL(backendHttpUrl);
2136 }
2137
2138 window.on("closed", () => {
2139 desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
2140 "SSH authentication was cancelled because the app window closed.",
2141 );
2142 if (mainWindow === window) {
2143 mainWindow = null;
2144 }
2145 });
2146
2147 return window;
2148}
2149
2150// Override Electron's userData path before the `ready` event so that
2151// Chromium session data uses a filesystem-friendly directory name.
2152// Must be called synchronously at the top level — before `app.whenReady()`.
2153app.setPath("userData", resolveUserDataPath());
2154
2155configureAppIdentity();
2156
2157async function bootstrap(): Promise<void> {
2158 writeDesktopLogHeader("bootstrap start");
2159 const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT);
2160 if (isDevelopment && configuredBackendPort === undefined) {
2161 throw new Error("T3CODE_PORT is required in desktop development.");
2162 }
2163
2164 backendPort =
2165 configuredBackendPort ??
2166 (await resolveDesktopBackendPort({
2167 host: DESKTOP_LOOPBACK_HOST,
2168 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
2169 requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
2170 }));
2171 writeDesktopLogHeader(
2172 configuredBackendPort === undefined
2173 ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}`
2174 : `using configured backend port port=${backendPort}`,
2175 );
2176 backendBootstrapToken = Crypto.randomBytes(24).toString("hex");
2177 if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) {
2178 writeDesktopLogHeader(
2179 `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
2180 );
2181 }
2182 const serverExposureState = await applyDesktopServerExposureMode(
2183 desktopSettings.serverExposureMode,
2184 {
2185 persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
2186 },
2187 );
2188 writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
2189 if (serverExposureState.endpointUrl) {
2190 writeDesktopLogHeader(
2191 `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
2192 );
2193 } else if (desktopSettings.serverExposureMode === "network-accessible") {
2194 writeDesktopLogHeader(
2195 "bootstrap fell back to local-only because no advertised network host was available",
2196 );
2197 }
2198
2199 registerIpcHandlers();
2200 writeDesktopLogHeader("bootstrap ipc handlers registered");
2201 startBackend();
2202 writeDesktopLogHeader("bootstrap backend start requested");
2203
2204 if (isDevelopment) {
2205 mainWindow = createWindow();
2206 writeDesktopLogHeader("bootstrap main window created");
2207 void waitForBackendWindowReady(backendHttpUrl)
2208 .then((source) => {
2209 writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
2210 })
2211 .catch((error) => {
2212 if (isBackendReadinessAborted(error)) {
2213 return;
2214 }
2215 writeDesktopLogHeader(
2216 `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
2217 );
2218 console.warn("[desktop] backend readiness check timed out during dev bootstrap", error);
2219 });
2220 return;
2221 }
2222
2223 ensureInitialBackendWindowOpen();
2224}
2225
2226app.on("before-quit", () => {
2227 isQuitting = true;
2228 updateInstallInFlight = false;
2229 writeDesktopLogHeader("before-quit received");
2230 clearUpdatePollTimer();
2231 cancelBackendReadinessWait();
2232 stopBackend();
2233 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2234 restoreStdIoCapture?.();
2235});
2236
2237app
2238 .whenReady()
2239 .then(() => {
2240 writeDesktopLogHeader("app ready");
2241 configureAppIdentity();
2242 configureApplicationMenu();
2243 registerDesktopProtocol();
2244 configureAutoUpdater();
2245 void bootstrap().catch((error) => {
2246 if (isBackendReadinessAborted(error) && isQuitting) {
2247 return;
2248 }
2249 handleFatalStartupError("bootstrap", error);
2250 });
225178
2252 app.on("activate", () => {79const desktopSshEnvironmentLayer = Layer.unwrap(
2253 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];80 Effect.gen(function* () {
2254 if (existingWindow) {81 const environment = yield* DesktopEnvironment.DesktopEnvironment;
2255 revealWindow(existingWindow);82 const settings = yield* DesktopAppSettings.DesktopAppSettings;
2256 return;83 return DesktopSshEnvironment.layer({
2257 }84 resolveCliRunner: settings.get.pipe(
2258 if (isDevelopment) {85 Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)),
2259 mainWindow = createWindow();86 ),
2260 return;
2261 }
2262 ensureInitialBackendWindowOpen();
2263 });87 });
2264 })88 }),
2265 .catch((error) => {89);
2266 handleFatalStartupError("whenReady", error);90
2267 });91const electronLayer = Layer.mergeAll(
226892 ElectronApp.layer,
2269app.on("window-all-closed", () => {93 ElectronDialog.layer,
2270 if (process.platform !== "darwin" && !isQuitting) {94 ElectronMenu.layer,
2271 app.quit();95 ElectronProtocol.layer,
2272 }96 DesktopSecretStorage.layer,
2273});97 ElectronShell.layer,
227498 ElectronTheme.layer,
2275if (process.platform !== "win32") {99 ElectronUpdater.layer,
2276 process.on("SIGINT", () => {100 ElectronWindow.layer,
2277 if (isQuitting) return;101 Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)),
2278 isQuitting = true;102);
2279 writeDesktopLogHeader("SIGINT received");103
2280 clearUpdatePollTimer();104const desktopFoundationLayer = Layer.mergeAll(
2281 cancelBackendReadinessWait();105 DesktopState.layer,
2282 stopBackend();106 DesktopLifecycle.layerShutdown,
2283 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);107 DesktopAppSettings.layer,
2284 restoreStdIoCapture?.();108 DesktopClientSettings.layer,
2285 app.quit();109 DesktopSavedEnvironments.layer,
2286 });110 DesktopAssets.layer,
2287111 DesktopObservability.layer,
2288 process.on("SIGTERM", () => {112).pipe(Layer.provideMerge(desktopEnvironmentLayer));
2289 if (isQuitting) return;113
2290 isQuitting = true;114const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe(
2291 writeDesktopLogHeader("SIGTERM received");115 Layer.provideMerge(DesktopSshPasswordPrompts.layer()),
2292 clearUpdatePollTimer();116);
2293 stopBackend();117
2294 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);118const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
2295 restoreStdIoCapture?.();119 Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer),
2296 app.quit();120 Layer.provideMerge(desktopFoundationLayer),
2297 });121);
2298}122
123const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer));
124
125const desktopBackendLayer = DesktopBackendManager.layer.pipe(
126 Layer.provideMerge(DesktopAppIdentity.layer),
127 Layer.provideMerge(DesktopBackendConfiguration.layer),
128 Layer.provideMerge(desktopWindowLayer),
129);
130
131const desktopApplicationLayer = Layer.mergeAll(
132 DesktopLifecycle.layer,
133 DesktopApplicationMenu.layer,
134 DesktopShellEnvironment.layer,
135 desktopSshLayer,
136).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer));
137
138const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe(
139 Layer.flatMap(() =>
140 desktopApplicationLayer.pipe(
141 Layer.provideMerge(NodeServices.layer),
142 Layer.provideMerge(NodeHttpClient.layerUndici),
143 Layer.provideMerge(NetService.layer),
144 Layer.provideMerge(electronLayer),
145 ),
146 ),
147);
148
149DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain);
apps/desktop/src/preload.ts+54-74
...@@ -1,48 +1,14 @@...@@ -1,48 +1,14 @@
1import { contextBridge, ipcRenderer } from "electron";
2import type { DesktopBridge } from "@t3tools/contracts";1import type { DesktopBridge } from "@t3tools/contracts";
2import { contextBridge, ipcRenderer } from "electron";
33
4const PICK_FOLDER_CHANNEL = "desktop:pick-folder";4import * as IpcChannels from "./ipc/channels.ts";
5const CONFIRM_CHANNEL = "desktop:confirm";
6const SET_THEME_CHANNEL = "desktop:set-theme";
7const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
8const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
9const MENU_ACTION_CHANNEL = "desktop:menu-action";
10const UPDATE_STATE_CHANNEL = "desktop:update-state";
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
13const UPDATE_CHECK_CHANNEL = "desktop:update-check";
14const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
15const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
16const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
17const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
18const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
19const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
20const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
21const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
22const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
23const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
24const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
25const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
26const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
27const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
28const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
29const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
30const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
31const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
32const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
33const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
34const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
35const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
36const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
37const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
38const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
395
40function unwrapEnsureSshEnvironmentResult(result: unknown) {6function unwrapEnsureSshEnvironmentResult(result: unknown) {
41 if (7 if (
42 typeof result === "object" &&8 typeof result === "object" &&
43 result !== null &&9 result !== null &&
44 "type" in result &&10 "type" in result &&
45 result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT11 result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT
46 ) {12 ) {
47 const message =13 const message =
48 "message" in result && typeof result.message === "string"14 "message" in result && typeof result.message === "string"
...@@ -55,93 +21,107 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) {...@@ -55,93 +21,107 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) {
5521
56contextBridge.exposeInMainWorld("desktopBridge", {22contextBridge.exposeInMainWorld("desktopBridge", {
57 getAppBranding: () => {23 getAppBranding: () => {
58 const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);24 const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL);
59 if (typeof result !== "object" || result === null) {25 if (typeof result !== "object" || result === null) {
60 return null;26 return null;
61 }27 }
62 return result as ReturnType<DesktopBridge["getAppBranding"]>;28 return result as ReturnType<DesktopBridge["getAppBranding"]>;
63 },29 },
64 getLocalEnvironmentBootstrap: () => {30 getLocalEnvironmentBootstrap: () => {
65 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);31 const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
66 if (typeof result !== "object" || result === null) {32 if (typeof result !== "object" || result === null) {
67 return null;33 return null;
68 }34 }
69 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;35 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
70 },36 },
71 getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL),37 getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),
72 setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings),38 setClientSettings: (settings) =>
73 getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),39 ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings),
40 getSavedEnvironmentRegistry: () =>
41 ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),
74 setSavedEnvironmentRegistry: (records) =>42 setSavedEnvironmentRegistry: (records) =>
75 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),43 ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),
76 getSavedEnvironmentSecret: (environmentId) =>44 getSavedEnvironmentSecret: (environmentId) =>
77 ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),45 ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
78 setSavedEnvironmentSecret: (environmentId, secret) =>46 setSavedEnvironmentSecret: (environmentId, secret) =>
79 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),47 ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }),
80 removeSavedEnvironmentSecret: (environmentId) =>48 removeSavedEnvironmentSecret: (environmentId) =>
81 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),49 ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
82 discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL),50 discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
83 ensureSshEnvironment: async (target, options) =>51 ensureSshEnvironment: async (target, options) =>
84 unwrapEnsureSshEnvironmentResult(52 unwrapEnsureSshEnvironmentResult(
85 await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options),53 await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, {
54 target,
55 ...(options === undefined ? {} : { options }),
56 }),
86 ),57 ),
87 disconnectSshEnvironment: (target) =>58 disconnectSshEnvironment: (target) =>
88 ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target),59 ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target),
89 fetchSshEnvironmentDescriptor: (httpBaseUrl) =>60 fetchSshEnvironmentDescriptor: (httpBaseUrl) =>
90 ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl),61 ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }),
91 bootstrapSshBearerSession: (httpBaseUrl, credential) =>62 bootstrapSshBearerSession: (httpBaseUrl, credential) =>
92 ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential),63 ipcRenderer.invoke(IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, {
64 httpBaseUrl,
65 credential,
66 }),
93 fetchSshSessionState: (httpBaseUrl, bearerToken) =>67 fetchSshSessionState: (httpBaseUrl, bearerToken) =>
94 ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken),68 ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }),
95 issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>69 issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>
96 ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken),70 ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }),
97 onSshPasswordPrompt: (listener) => {71 onSshPasswordPrompt: (listener) => {
98 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {72 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
99 if (typeof request !== "object" || request === null) return;73 if (typeof request !== "object" || request === null) return;
100 listener(request as Parameters<typeof listener>[0]);74 listener(request as Parameters<typeof listener>[0]);
101 };75 };
10276
103 ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);77 ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
104 return () => {78 return () => {
105 ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);79 ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
106 };80 };
107 },81 },
108 resolveSshPasswordPrompt: (requestId, password) =>82 resolveSshPasswordPrompt: (requestId, password) =>
109 ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password),83 ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }),
110 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),84 getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL),
111 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),85 setServerExposureMode: (mode) =>
86 ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
112 setTailscaleServeEnabled: (input) =>87 setTailscaleServeEnabled: (input) =>
113 ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input),88 ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input),
114 getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL),89 getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL),
115 pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),90 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
116 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),91 confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
117 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),92 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
118 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),93 showContextMenu: (items, position) =>
119 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),94 ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
95 items,
96 ...(position === undefined ? {} : { position }),
97 }),
98 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
120 onMenuAction: (listener) => {99 onMenuAction: (listener) => {
121 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {100 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
122 if (typeof action !== "string") return;101 if (typeof action !== "string") return;
123 listener(action);102 listener(action);
124 };103 };
125104
126 ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener);105 ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
127 return () => {106 return () => {
128 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);107 ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
129 };108 };
130 },109 },
131 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),110 getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),
132 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),111 setUpdateChannel: (channel) =>
133 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),112 ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),
134 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),113 checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL),
135 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),114 downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL),
115 installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL),
136 onUpdateState: (listener) => {116 onUpdateState: (listener) => {
137 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {117 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
138 if (typeof state !== "object" || state === null) return;118 if (typeof state !== "object" || state === null) return;
139 listener(state as Parameters<typeof listener>[0]);119 listener(state as Parameters<typeof listener>[0]);
140 };120 };
141121
142 ipcRenderer.on(UPDATE_STATE_CHANNEL, wrappedListener);122 ipcRenderer.on(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
143 return () => {123 return () => {
144 ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener);124 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
145 };125 };
146 },126 },
147} satisfies DesktopBridge);127} satisfies DesktopBridge);
apps/desktop/src/rotatingFileSink.test.ts deleted-74
...@@ -1,74 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { RotatingFileSink } from "@t3tools/shared/logging";
6import { afterEach, describe, expect, it } from "vitest";
7
8const tempRoots: string[] = [];
9
10function makeTempDir(): string {
11 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-"));
12 tempRoots.push(dir);
13 return dir;
14}
15
16afterEach(() => {
17 for (const dir of tempRoots.splice(0)) {
18 fs.rmSync(dir, { recursive: true, force: true });
19 }
20});
21
22describe("RotatingFileSink", () => {
23 it("rotates when writes exceed max bytes", () => {
24 const dir = makeTempDir();
25 const logPath = path.join(dir, "desktop-main.log");
26 const sink = new RotatingFileSink({
27 filePath: logPath,
28 maxBytes: 10,
29 maxFiles: 3,
30 });
31
32 sink.write("12345");
33 sink.write("67890");
34 sink.write("abc");
35
36 expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc");
37 expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890");
38 });
39
40 it("retains only maxFiles backups", () => {
41 const dir = makeTempDir();
42 const logPath = path.join(dir, "server-child.log");
43 const sink = new RotatingFileSink({
44 filePath: logPath,
45 maxBytes: 4,
46 maxFiles: 2,
47 });
48
49 sink.write("aaaa");
50 sink.write("bbbb");
51 sink.write("cccc");
52 sink.write("dddd");
53
54 expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true);
55 expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true);
56 expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false);
57 });
58
59 it("prunes stale backups above maxFiles on startup", () => {
60 const dir = makeTempDir();
61 const logPath = path.join(dir, "desktop-main.log");
62 fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first");
63 fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale");
64
65 const sink = new RotatingFileSink({
66 filePath: logPath,
67 maxBytes: 16,
68 maxFiles: 2,
69 });
70 sink.write("hello");
71
72 expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false);
73 });
74});
apps/desktop/src/runtimeArch.test.ts deleted-49
...@@ -1,49 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
4
5describe("resolveDesktopRuntimeInfo", () => {
6 it("detects Rosetta-translated Intel builds on Apple Silicon", () => {
7 const runtimeInfo = resolveDesktopRuntimeInfo({
8 platform: "darwin",
9 processArch: "x64",
10 runningUnderArm64Translation: true,
11 });
12
13 expect(runtimeInfo).toEqual({
14 hostArch: "arm64",
15 appArch: "x64",
16 runningUnderArm64Translation: true,
17 });
18 expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true);
19 });
20
21 it("detects native Apple Silicon builds", () => {
22 const runtimeInfo = resolveDesktopRuntimeInfo({
23 platform: "darwin",
24 processArch: "arm64",
25 runningUnderArm64Translation: false,
26 });
27
28 expect(runtimeInfo).toEqual({
29 hostArch: "arm64",
30 appArch: "arm64",
31 runningUnderArm64Translation: false,
32 });
33 expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false);
34 });
35
36 it("passes through non-mac builds without translation", () => {
37 const runtimeInfo = resolveDesktopRuntimeInfo({
38 platform: "linux",
39 processArch: "x64",
40 runningUnderArm64Translation: true,
41 });
42
43 expect(runtimeInfo).toEqual({
44 hostArch: "x64",
45 appArch: "x64",
46 runningUnderArm64Translation: false,
47 });
48 });
49});
apps/desktop/src/runtimeArch.ts deleted-39
...@@ -1,39 +0,0 @@
1import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts";
2
3interface ResolveDesktopRuntimeInfoInput {
4 readonly platform: NodeJS.Platform;
5 readonly processArch: string;
6 readonly runningUnderArm64Translation: boolean;
7}
8
9function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
10 if (arch === "arm64") return "arm64";
11 if (arch === "x64") return "x64";
12 return "other";
13}
14
15export function resolveDesktopRuntimeInfo(
16 input: ResolveDesktopRuntimeInfoInput,
17): DesktopRuntimeInfo {
18 const appArch = normalizeDesktopArch(input.processArch);
19
20 if (input.platform !== "darwin") {
21 return {
22 hostArch: appArch,
23 appArch,
24 runningUnderArm64Translation: false,
25 };
26 }
27
28 const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
29
30 return {
31 hostArch,
32 appArch,
33 runningUnderArm64Translation: input.runningUnderArm64Translation,
34 };
35}
36
37export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
38 return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
39}
apps/desktop/src/serverExposure.test.ts deleted-258
...@@ -1,258 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 resolveDesktopCoreAdvertisedEndpoints,
5 resolveDesktopServerExposure,
6 resolveLanAdvertisedHost,
7} from "./serverExposure.ts";
8
9describe("resolveLanAdvertisedHost", () => {
10 it("prefers an explicit host override", () => {
11 expect(
12 resolveLanAdvertisedHost(
13 {
14 en0: [
15 {
16 address: "192.168.1.44",
17 family: "IPv4",
18 internal: false,
19 netmask: "255.255.255.0",
20 cidr: "192.168.1.44/24",
21 mac: "00:00:00:00:00:00",
22 },
23 ],
24 },
25 "10.0.0.9",
26 ),
27 ).toBe("10.0.0.9");
28 });
29
30 it("returns the first usable non-internal IPv4 address", () => {
31 expect(
32 resolveLanAdvertisedHost(
33 {
34 lo0: [
35 {
36 address: "127.0.0.1",
37 family: "IPv4",
38 internal: true,
39 netmask: "255.0.0.0",
40 cidr: "127.0.0.1/8",
41 mac: "00:00:00:00:00:00",
42 },
43 ],
44 en0: [
45 {
46 address: "192.168.1.44",
47 family: "IPv4",
48 internal: false,
49 netmask: "255.255.255.0",
50 cidr: "192.168.1.44/24",
51 mac: "00:00:00:00:00:00",
52 },
53 ],
54 },
55 undefined,
56 ),
57 ).toBe("192.168.1.44");
58 });
59
60 it("returns null when no usable network address is available", () => {
61 expect(
62 resolveLanAdvertisedHost(
63 {
64 lo0: [
65 {
66 address: "127.0.0.1",
67 family: "IPv4",
68 internal: true,
69 netmask: "255.0.0.0",
70 cidr: "127.0.0.1/8",
71 mac: "00:00:00:00:00:00",
72 },
73 ],
74 },
75 undefined,
76 ),
77 ).toBeNull();
78 });
79});
80
81describe("resolveDesktopCoreAdvertisedEndpoints", () => {
82 it("advertises loopback and LAN endpoints without provider-specific assumptions", () => {
83 const exposure = resolveDesktopServerExposure({
84 mode: "network-accessible",
85 port: 3773,
86 networkInterfaces: {
87 en0: [
88 {
89 address: "192.168.1.44",
90 family: "IPv4",
91 internal: false,
92 netmask: "255.255.255.0",
93 cidr: "192.168.1.44/24",
94 mac: "00:00:00:00:00:00",
95 },
96 ],
97 },
98 });
99
100 expect(
101 resolveDesktopCoreAdvertisedEndpoints({
102 port: 3773,
103 exposure,
104 customHttpsEndpointUrls: [
105 "https://desktop.example.ts.net",
106 "http://desktop.example.test:3773",
107 "not-a-url",
108 ],
109 }),
110 ).toEqual([
111 {
112 id: "desktop-loopback:3773",
113 label: "This machine",
114 provider: {
115 id: "desktop-core",
116 label: "Desktop",
117 kind: "core",
118 isAddon: false,
119 },
120 httpBaseUrl: "http://127.0.0.1:3773/",
121 wsBaseUrl: "ws://127.0.0.1:3773/",
122 reachability: "loopback",
123 compatibility: {
124 hostedHttpsApp: "mixed-content-blocked",
125 desktopApp: "compatible",
126 },
127 source: "desktop-core",
128 status: "available",
129 description: "Loopback endpoint for this desktop app.",
130 },
131 {
132 id: "desktop-lan:http://192.168.1.44:3773",
133 label: "Local network",
134 provider: {
135 id: "desktop-core",
136 label: "Desktop",
137 kind: "core",
138 isAddon: false,
139 },
140 httpBaseUrl: "http://192.168.1.44:3773/",
141 wsBaseUrl: "ws://192.168.1.44:3773/",
142 reachability: "lan",
143 compatibility: {
144 hostedHttpsApp: "mixed-content-blocked",
145 desktopApp: "compatible",
146 },
147 source: "desktop-core",
148 status: "available",
149 isDefault: true,
150 description: "Reachable from devices on the same network.",
151 },
152 {
153 id: "manual:https://desktop.example.ts.net",
154 label: "Custom HTTPS",
155 provider: {
156 id: "manual",
157 label: "Manual",
158 kind: "manual",
159 isAddon: false,
160 },
161 httpBaseUrl: "https://desktop.example.ts.net/",
162 wsBaseUrl: "wss://desktop.example.ts.net/",
163 reachability: "public",
164 compatibility: {
165 hostedHttpsApp: "compatible",
166 desktopApp: "compatible",
167 },
168 source: "user",
169 status: "unknown",
170 description: "User-configured HTTPS endpoint for this desktop backend.",
171 },
172 {
173 id: "manual:http://desktop.example.test:3773",
174 label: "Custom endpoint",
175 provider: {
176 id: "manual",
177 label: "Manual",
178 kind: "manual",
179 isAddon: false,
180 },
181 httpBaseUrl: "http://desktop.example.test:3773/",
182 wsBaseUrl: "ws://desktop.example.test:3773/",
183 reachability: "public",
184 compatibility: {
185 hostedHttpsApp: "mixed-content-blocked",
186 desktopApp: "compatible",
187 },
188 source: "user",
189 status: "unknown",
190 description: "User-configured endpoint for this desktop backend.",
191 },
192 ]);
193 });
194});
195
196describe("resolveDesktopServerExposure", () => {
197 it("keeps the desktop server loopback-only when local-only mode is selected", () => {
198 expect(
199 resolveDesktopServerExposure({
200 mode: "local-only",
201 port: 3773,
202 networkInterfaces: {},
203 }),
204 ).toEqual({
205 mode: "local-only",
206 bindHost: "127.0.0.1",
207 localHttpUrl: "http://127.0.0.1:3773",
208 localWsUrl: "ws://127.0.0.1:3773",
209 endpointUrl: null,
210 advertisedHost: null,
211 });
212 });
213
214 it("binds to all interfaces in network-accessible mode", () => {
215 expect(
216 resolveDesktopServerExposure({
217 mode: "network-accessible",
218 port: 3773,
219 networkInterfaces: {
220 en0: [
221 {
222 address: "192.168.1.44",
223 family: "IPv4",
224 internal: false,
225 netmask: "255.255.255.0",
226 cidr: "192.168.1.44/24",
227 mac: "00:00:00:00:00:00",
228 },
229 ],
230 },
231 }),
232 ).toEqual({
233 mode: "network-accessible",
234 bindHost: "0.0.0.0",
235 localHttpUrl: "http://127.0.0.1:3773",
236 localWsUrl: "ws://127.0.0.1:3773",
237 endpointUrl: "http://192.168.1.44:3773",
238 advertisedHost: "192.168.1.44",
239 });
240 });
241
242 it("stays network-accessible even when no LAN address is currently detectable", () => {
243 expect(
244 resolveDesktopServerExposure({
245 mode: "network-accessible",
246 port: 3773,
247 networkInterfaces: {},
248 }),
249 ).toEqual({
250 mode: "network-accessible",
251 bindHost: "0.0.0.0",
252 localHttpUrl: "http://127.0.0.1:3773",
253 localWsUrl: "ws://127.0.0.1:3773",
254 endpointUrl: null,
255 advertisedHost: null,
256 });
257 });
258});
apps/desktop/src/serverExposure.ts deleted-188
...@@ -1,188 +0,0 @@
1import type { NetworkInterfaceInfo } from "node:os";
2import {
3 createAdvertisedEndpoint,
4 type CreateAdvertisedEndpointInput,
5} from "@t3tools/client-runtime";
6import type {
7 AdvertisedEndpoint,
8 AdvertisedEndpointProvider,
9 DesktopServerExposureMode,
10} from "@t3tools/contracts";
11
12const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
13const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
14
15export interface DesktopServerExposure {
16 readonly mode: DesktopServerExposureMode;
17 readonly bindHost: string;
18 readonly localHttpUrl: string;
19 readonly localWsUrl: string;
20 readonly endpointUrl: string | null;
21 readonly advertisedHost: string | null;
22}
23
24export interface DesktopAdvertisedEndpointInput {
25 readonly port: number;
26 readonly exposure: DesktopServerExposure;
27 readonly customHttpsEndpointUrls?: readonly string[];
28}
29
30const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
31 id: "desktop-core",
32 label: "Desktop",
33 kind: "core",
34 isAddon: false,
35};
36
37const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
38 id: "manual",
39 label: "Manual",
40 kind: "manual",
41 isAddon: false,
42};
43
44const normalizeOptionalHost = (value: string | undefined): string | undefined => {
45 const normalized = value?.trim();
46 return normalized && normalized.length > 0 ? normalized : undefined;
47};
48
49const isUsableLanIpv4Address = (address: string): boolean =>
50 !address.startsWith("127.") && !address.startsWith("169.254.");
51
52function isHttpsEndpointUrl(value: string): boolean {
53 try {
54 return new URL(value).protocol === "https:";
55 } catch {
56 return false;
57 }
58}
59
60export function resolveLanAdvertisedHost(
61 networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
62 explicitHost: string | undefined,
63): string | null {
64 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
65 if (normalizedExplicitHost) {
66 return normalizedExplicitHost;
67 }
68
69 for (const interfaceAddresses of Object.values(networkInterfaces)) {
70 if (!interfaceAddresses) continue;
71
72 for (const address of interfaceAddresses) {
73 if (address.internal) continue;
74 if (address.family !== "IPv4") continue;
75 if (!isUsableLanIpv4Address(address.address)) continue;
76 return address.address;
77 }
78 }
79
80 return null;
81}
82
83export function resolveDesktopServerExposure(input: {
84 readonly mode: DesktopServerExposureMode;
85 readonly port: number;
86 readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
87 readonly advertisedHostOverride?: string;
88}): DesktopServerExposure {
89 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
90 const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
91
92 if (input.mode === "local-only") {
93 return {
94 mode: input.mode,
95 bindHost: DESKTOP_LOOPBACK_HOST,
96 localHttpUrl,
97 localWsUrl,
98 endpointUrl: null,
99 advertisedHost: null,
100 };
101 }
102
103 const advertisedHost = resolveLanAdvertisedHost(
104 input.networkInterfaces,
105 input.advertisedHostOverride,
106 );
107
108 return {
109 mode: input.mode,
110 bindHost: DESKTOP_LAN_BIND_HOST,
111 localHttpUrl,
112 localWsUrl,
113 endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
114 advertisedHost,
115 };
116}
117
118function createDesktopEndpoint(
119 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
120): AdvertisedEndpoint {
121 return createAdvertisedEndpoint({
122 ...input,
123 provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
124 source: "desktop-core",
125 });
126}
127
128function createManualEndpoint(
129 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
130): AdvertisedEndpoint {
131 return createAdvertisedEndpoint({
132 ...input,
133 provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
134 source: "user",
135 });
136}
137
138export function resolveDesktopCoreAdvertisedEndpoints(
139 input: DesktopAdvertisedEndpointInput,
140): readonly AdvertisedEndpoint[] {
141 const endpoints: AdvertisedEndpoint[] = [
142 createDesktopEndpoint({
143 id: `desktop-loopback:${input.port}`,
144 label: "This machine",
145 httpBaseUrl: input.exposure.localHttpUrl,
146 reachability: "loopback",
147 status: "available",
148 description: "Loopback endpoint for this desktop app.",
149 }),
150 ];
151
152 if (input.exposure.endpointUrl) {
153 endpoints.push(
154 createDesktopEndpoint({
155 id: `desktop-lan:${input.exposure.endpointUrl}`,
156 label: "Local network",
157 httpBaseUrl: input.exposure.endpointUrl,
158 reachability: "lan",
159 status: "available",
160 isDefault: true,
161 description: "Reachable from devices on the same network.",
162 }),
163 );
164 }
165
166 for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
167 try {
168 const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
169 endpoints.push(
170 createManualEndpoint({
171 id: `manual:${customEndpointUrl}`,
172 label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
173 httpBaseUrl: customEndpointUrl,
174 reachability: "public",
175 ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
176 status: "unknown",
177 description: isHttpsEndpoint
178 ? "User-configured HTTPS endpoint for this desktop backend."
179 : "User-configured endpoint for this desktop backend.",
180 }),
181 );
182 } catch {
183 // Ignore malformed user-configured endpoints without dropping valid endpoints.
184 }
185 }
186
187 return endpoints;
188}
apps/desktop/src/serverExposure/DesktopServerExposure.test.ts created+365
...@@ -0,0 +1,365 @@
1import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
2import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import {
13 DesktopEnvironment,
14 layer as makeDesktopEnvironmentLayer,
15} from "../app/DesktopEnvironment.ts";
16import * as DesktopConfig from "../app/DesktopConfig.ts";
17import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
19import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
20
21const encoder = new TextEncoder();
22
23const emptyNetworkInterfaces: DesktopNetworkInterfaces = {};
24const lanNetworkInterfaces: DesktopNetworkInterfaces = {
25 en0: [
26 {
27 address: "192.168.1.20",
28 family: "IPv4",
29 internal: false,
30 },
31 ],
32};
33
34const tailnetNetworkInterfaces: DesktopNetworkInterfaces = {
35 tailscale0: [
36 {
37 address: "100.90.1.2",
38 family: "IPv4",
39 internal: false,
40 },
41 ],
42};
43
44function mockSpawnerLayer(statusJson = "{}") {
45 return Layer.succeed(
46 ChildProcessSpawner.ChildProcessSpawner,
47 ChildProcessSpawner.make(() =>
48 Effect.succeed(
49 ChildProcessSpawner.makeHandle({
50 pid: ChildProcessSpawner.ProcessId(1),
51 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
52 isRunning: Effect.succeed(false),
53 kill: () => Effect.void,
54 unref: Effect.succeed(Effect.void),
55 stdin: Sink.drain,
56 stdout: Stream.make(encoder.encode(statusJson)),
57 stderr: Stream.empty,
58 all: Stream.empty,
59 getInputFd: () => Sink.drain,
60 getOutputFd: () => Stream.empty,
61 }),
62 ),
63 ),
64 );
65}
66
67function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
68 return makeDesktopEnvironmentLayer({
69 dirname: "/repo/apps/desktop/src",
70 homeDirectory: baseDir,
71 platform: "darwin",
72 processArch: "x64",
73 appVersion: "1.2.3",
74 appPath: "/repo",
75 isPackaged: true,
76 resourcesPath: "/missing/resources",
77 runningUnderArm64Translation: false,
78 }).pipe(
79 Layer.provide(
80 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })),
81 ),
82 );
83}
84
85function makeLayer(input: {
86 readonly baseDir: string;
87 readonly networkInterfaces?: DesktopNetworkInterfaces;
88 readonly env?: Record<string, string | undefined>;
89}) {
90 const env = { T3CODE_HOME: input.baseDir, ...input.env };
91 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
92 const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, {
93 read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
94 });
95
96 return DesktopServerExposure.layer.pipe(
97 Layer.provideMerge(DesktopAppSettings.layer),
98 Layer.provideMerge(NodeFileSystem.layer),
99 Layer.provideMerge(NodeHttpClient.layerUndici),
100 Layer.provideMerge(mockSpawnerLayer()),
101 Layer.provideMerge(networkLayer),
102 Layer.provideMerge(DesktopConfig.layerTest(env)),
103 Layer.provideMerge(environmentLayer),
104 );
105}
106
107const withHarness = <A, E, R>(
108 networkInterfaces: DesktopNetworkInterfaces,
109 effect: Effect.Effect<
110 A,
111 E,
112 | R
113 | DesktopEnvironment
114 | FileSystem.FileSystem
115 | DesktopServerExposure.DesktopServerExposure
116 | DesktopAppSettings.DesktopAppSettings
117 >,
118 env: Record<string, string | undefined> = {},
119) =>
120 Effect.gen(function* () {
121 const fileSystem = yield* FileSystem.FileSystem;
122 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
123 prefix: "t3-desktop-server-exposure-test-",
124 });
125 return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env })));
126 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
127
128describe("DesktopServerExposure", () => {
129 it.effect("falls back to local-only without losing the requested network preference", () =>
130 withHarness(
131 emptyNetworkInterfaces,
132 Effect.gen(function* () {
133 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
134 const settings = yield* DesktopAppSettings.DesktopAppSettings;
135
136 yield* settings.setServerExposureMode("network-accessible");
137
138 const state = yield* serverExposure.configureFromSettings({ port: 4173 });
139 assert.equal(state.mode, "local-only");
140 assert.equal(state.endpointUrl, null);
141 assert.equal((yield* settings.get).serverExposureMode, "network-accessible");
142
143 const backendConfig = yield* serverExposure.backendConfig;
144 assert.equal(backendConfig.bindHost, "127.0.0.1");
145 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
146 }),
147 ),
148 );
149
150 it.effect("returns a typed error when network access is explicitly unavailable", () =>
151 withHarness(
152 emptyNetworkInterfaces,
153 Effect.gen(function* () {
154 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
155 yield* serverExposure.configureFromSettings({ port: 4173 });
156
157 const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
158 assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError");
159 assert.equal(error.port, 4173);
160 }),
161 ),
162 );
163
164 it.effect("persists network-accessible mode and updates backend binding state", () =>
165 withHarness(
166 lanNetworkInterfaces,
167 Effect.gen(function* () {
168 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
169 const settings = yield* DesktopAppSettings.DesktopAppSettings;
170
171 yield* settings.load;
172 yield* serverExposure.configureFromSettings({ port: 4173 });
173
174 const change = yield* serverExposure.setMode("network-accessible");
175 assert.equal(change.requiresRelaunch, true);
176 assert.deepEqual(change.state, {
177 mode: "network-accessible",
178 endpointUrl: "http://192.168.1.20:4173",
179 advertisedHost: "192.168.1.20",
180 tailscaleServeEnabled: false,
181 tailscaleServePort: 443,
182 });
183
184 const backendConfig = yield* serverExposure.backendConfig;
185 assert.equal(backendConfig.bindHost, "0.0.0.0");
186 assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
187
188 const persisted = yield* settings.get;
189 assert.equal(persisted.serverExposureMode, "network-accessible");
190 }),
191 ),
192 );
193
194 it.effect("persists tailscale serve preferences atomically and reports no-op updates", () =>
195 withHarness(
196 emptyNetworkInterfaces,
197 Effect.gen(function* () {
198 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
199 const settings = yield* DesktopAppSettings.DesktopAppSettings;
200
201 yield* settings.load;
202 yield* serverExposure.configureFromSettings({ port: 4173 });
203
204 const changed = yield* serverExposure.setTailscaleServeEnabled({
205 enabled: true,
206 port: 8443,
207 });
208 assert.equal(changed.requiresRelaunch, true);
209 assert.equal(changed.state.tailscaleServeEnabled, true);
210 assert.equal(changed.state.tailscaleServePort, 8443);
211
212 const unchanged = yield* serverExposure.setTailscaleServeEnabled({
213 enabled: true,
214 port: 8443,
215 });
216 assert.equal(unchanged.requiresRelaunch, false);
217
218 const persisted = yield* settings.get;
219 assert.equal(persisted.tailscaleServeEnabled, true);
220 assert.equal(persisted.tailscaleServePort, 8443);
221 }),
222 ),
223 );
224
225 it.effect("resolves advertised endpoints from the scoped runtime state", () =>
226 withHarness(
227 { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },
228 Effect.gen(function* () {
229 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
230 yield* serverExposure.configureFromSettings({ port: 4173 });
231 yield* serverExposure.setMode("network-accessible");
232
233 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
234 assert.deepEqual(
235 endpoints.map((endpoint) => endpoint.httpBaseUrl),
236 ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"],
237 );
238 }),
239 ),
240 );
241
242 it.effect("uses ConfigProvider desktop exposure overrides", () =>
243 withHarness(
244 lanNetworkInterfaces,
245 Effect.gen(function* () {
246 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
247 yield* serverExposure.configureFromSettings({ port: 4173 });
248 const change = yield* serverExposure.setMode("network-accessible");
249
250 assert.equal(change.state.advertisedHost, "10.0.0.7");
251 assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173");
252
253 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
254 assert.deepEqual(
255 endpoints.map((endpoint) => endpoint.httpBaseUrl),
256 ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"],
257 );
258 }),
259 {
260 T3CODE_DESKTOP_LAN_HOST: "10.0.0.7",
261 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
262 },
263 ),
264 );
265
266 it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () =>
267 withHarness(
268 lanNetworkInterfaces,
269 Effect.gen(function* () {
270 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
271 yield* serverExposure.configureFromSettings({ port: 3773 });
272 yield* serverExposure.setMode("network-accessible");
273
274 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
275 assert.deepEqual(endpoints, [
276 {
277 id: "desktop-loopback:3773",
278 label: "This machine",
279 provider: {
280 id: "desktop-core",
281 label: "Desktop",
282 kind: "core",
283 isAddon: false,
284 },
285 httpBaseUrl: "http://127.0.0.1:3773/",
286 wsBaseUrl: "ws://127.0.0.1:3773/",
287 reachability: "loopback",
288 compatibility: {
289 hostedHttpsApp: "mixed-content-blocked",
290 desktopApp: "compatible",
291 },
292 source: "desktop-core",
293 status: "available",
294 description: "Loopback endpoint for this desktop app.",
295 },
296 {
297 id: "desktop-lan:http://192.168.1.20:3773",
298 label: "Local network",
299 provider: {
300 id: "desktop-core",
301 label: "Desktop",
302 kind: "core",
303 isAddon: false,
304 },
305 httpBaseUrl: "http://192.168.1.20:3773/",
306 wsBaseUrl: "ws://192.168.1.20:3773/",
307 reachability: "lan",
308 compatibility: {
309 hostedHttpsApp: "mixed-content-blocked",
310 desktopApp: "compatible",
311 },
312 source: "desktop-core",
313 status: "available",
314 isDefault: true,
315 description: "Reachable from devices on the same network.",
316 },
317 {
318 id: "manual:https://desktop.example.ts.net",
319 label: "Custom HTTPS",
320 provider: {
321 id: "manual",
322 label: "Manual",
323 kind: "manual",
324 isAddon: false,
325 },
326 httpBaseUrl: "https://desktop.example.ts.net/",
327 wsBaseUrl: "wss://desktop.example.ts.net/",
328 reachability: "public",
329 compatibility: {
330 hostedHttpsApp: "compatible",
331 desktopApp: "compatible",
332 },
333 source: "user",
334 status: "unknown",
335 description: "User-configured HTTPS endpoint for this desktop backend.",
336 },
337 {
338 id: "manual:http://desktop.example.test:3773",
339 label: "Custom endpoint",
340 provider: {
341 id: "manual",
342 label: "Manual",
343 kind: "manual",
344 isAddon: false,
345 },
346 httpBaseUrl: "http://desktop.example.test:3773/",
347 wsBaseUrl: "ws://desktop.example.test:3773/",
348 reachability: "public",
349 compatibility: {
350 hostedHttpsApp: "mixed-content-blocked",
351 desktopApp: "compatible",
352 },
353 source: "user",
354 status: "unknown",
355 description: "User-configured endpoint for this desktop backend.",
356 },
357 ]);
358 }),
359 {
360 T3CODE_DESKTOP_HTTPS_ENDPOINTS:
361 "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url",
362 },
363 ),
364 );
365});
apps/desktop/src/serverExposure/DesktopServerExposure.ts created+548
...@@ -0,0 +1,548 @@
1import * as NodeOS from "node:os";
2
3import {
4 createAdvertisedEndpoint,
5 type CreateAdvertisedEndpointInput,
6} from "@t3tools/client-runtime";
7import type {
8 AdvertisedEndpoint,
9 AdvertisedEndpointProvider,
10 DesktopServerExposureMode,
11 DesktopServerExposureState,
12} from "@t3tools/contracts";
13import * as Context from "effect/Context";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import { HttpClient } from "effect/unstable/http";
20import { ChildProcessSpawner } from "effect/unstable/process";
21
22import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
23import * as DesktopConfig from "../app/DesktopConfig.ts";
24import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
25import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
26
27export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
28const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
29
30export interface DesktopNetworkInterfaceInfo {
31 readonly address: string;
32 readonly family: string | number;
33 readonly internal: boolean;
34 readonly netmask?: string;
35 readonly mac?: string;
36 readonly cidr?: string | null;
37 readonly scopeid?: number;
38}
39
40export type DesktopNetworkInterfaces = Readonly<
41 Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
42>;
43
44interface ResolvedDesktopServerExposure {
45 readonly mode: DesktopServerExposureMode;
46 readonly bindHost: string;
47 readonly localHttpUrl: string;
48 readonly localWsUrl: string;
49 readonly endpointUrl: string | null;
50 readonly advertisedHost: string | null;
51}
52
53interface DesktopAdvertisedEndpointInput {
54 readonly port: number;
55 readonly exposure: ResolvedDesktopServerExposure;
56 readonly customHttpsEndpointUrls?: readonly string[];
57}
58
59const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
60 id: "desktop-core",
61 label: "Desktop",
62 kind: "core",
63 isAddon: false,
64};
65
66const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
67 id: "manual",
68 label: "Manual",
69 kind: "manual",
70 isAddon: false,
71};
72
73const normalizeOptionalHost = (value: string | undefined): string | undefined => {
74 const normalized = value?.trim();
75 return normalized && normalized.length > 0 ? normalized : undefined;
76};
77
78const isUsableLanIpv4Address = (address: string): boolean =>
79 !address.startsWith("127.") && !address.startsWith("169.254.");
80
81const isHttpsEndpointUrl = (value: string): boolean => {
82 try {
83 return new URL(value).protocol === "https:";
84 } catch {
85 return false;
86 }
87};
88
89const resolveLanAdvertisedHost = (
90 networkInterfaces: DesktopNetworkInterfaces,
91 explicitHost: string | undefined,
92): string | null => {
93 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
94 if (normalizedExplicitHost) {
95 return normalizedExplicitHost;
96 }
97
98 for (const interfaceAddresses of Object.values(networkInterfaces)) {
99 if (!interfaceAddresses) continue;
100
101 for (const address of interfaceAddresses) {
102 if (address.internal) continue;
103 if (address.family !== "IPv4") continue;
104 if (!isUsableLanIpv4Address(address.address)) continue;
105 return address.address;
106 }
107 }
108
109 return null;
110};
111
112const resolveDesktopServerExposure = (input: {
113 readonly mode: DesktopServerExposureMode;
114 readonly port: number;
115 readonly networkInterfaces: DesktopNetworkInterfaces;
116 readonly advertisedHostOverride?: string;
117}): ResolvedDesktopServerExposure => {
118 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
119 const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
120
121 if (input.mode === "local-only") {
122 return {
123 mode: input.mode,
124 bindHost: DESKTOP_LOOPBACK_HOST,
125 localHttpUrl,
126 localWsUrl,
127 endpointUrl: null,
128 advertisedHost: null,
129 };
130 }
131
132 const advertisedHost = resolveLanAdvertisedHost(
133 input.networkInterfaces,
134 input.advertisedHostOverride,
135 );
136
137 return {
138 mode: input.mode,
139 bindHost: DESKTOP_LAN_BIND_HOST,
140 localHttpUrl,
141 localWsUrl,
142 endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
143 advertisedHost,
144 };
145};
146
147const createDesktopEndpoint = (
148 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
149): AdvertisedEndpoint =>
150 createAdvertisedEndpoint({
151 ...input,
152 provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
153 source: "desktop-core",
154 });
155
156const createManualEndpoint = (
157 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
158): AdvertisedEndpoint =>
159 createAdvertisedEndpoint({
160 ...input,
161 provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
162 source: "user",
163 });
164
165const resolveDesktopCoreAdvertisedEndpoints = (
166 input: DesktopAdvertisedEndpointInput,
167): readonly AdvertisedEndpoint[] => {
168 const endpoints: AdvertisedEndpoint[] = [
169 createDesktopEndpoint({
170 id: `desktop-loopback:${input.port}`,
171 label: "This machine",
172 httpBaseUrl: input.exposure.localHttpUrl,
173 reachability: "loopback",
174 status: "available",
175 description: "Loopback endpoint for this desktop app.",
176 }),
177 ];
178
179 if (input.exposure.endpointUrl) {
180 endpoints.push(
181 createDesktopEndpoint({
182 id: `desktop-lan:${input.exposure.endpointUrl}`,
183 label: "Local network",
184 httpBaseUrl: input.exposure.endpointUrl,
185 reachability: "lan",
186 status: "available",
187 isDefault: true,
188 description: "Reachable from devices on the same network.",
189 }),
190 );
191 }
192
193 for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
194 try {
195 const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
196 endpoints.push(
197 createManualEndpoint({
198 id: `manual:${customEndpointUrl}`,
199 label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
200 httpBaseUrl: customEndpointUrl,
201 reachability: "public",
202 ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
203 status: "unknown",
204 description: isHttpsEndpoint
205 ? "User-configured HTTPS endpoint for this desktop backend."
206 : "User-configured endpoint for this desktop backend.",
207 }),
208 );
209 } catch {
210 // Ignore malformed user-configured endpoints without dropping valid endpoints.
211 }
212 }
213
214 return endpoints;
215};
216
217type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve";
218
219export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError(
220 "DesktopServerExposureNoNetworkAddressError",
221)<{
222 readonly port: number;
223}> {
224 override get message() {
225 return `No reachable network address is available for desktop network access on port ${this.port}.`;
226 }
227}
228
229export class DesktopServerExposurePersistenceError extends Data.TaggedError(
230 "DesktopServerExposurePersistenceError",
231)<{
232 readonly operation: DesktopServerExposurePersistenceOperation;
233 readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError;
234}> {
235 override get message() {
236 return `Failed to persist desktop ${this.operation} settings.`;
237 }
238}
239
240export type DesktopServerExposureSetModeError =
241 | DesktopServerExposureNoNetworkAddressError
242 | DesktopServerExposurePersistenceError;
243
244export type DesktopServerExposureError = DesktopServerExposureSetModeError;
245
246export interface DesktopServerExposureBackendConfig {
247 readonly port: number;
248 readonly bindHost: string;
249 readonly httpBaseUrl: URL;
250 readonly tailscaleServeEnabled: boolean;
251 readonly tailscaleServePort: number;
252}
253
254export interface DesktopServerExposureChange {
255 readonly state: DesktopServerExposureState;
256 readonly requiresRelaunch: boolean;
257}
258
259export interface DesktopServerExposureShape {
260 readonly getState: Effect.Effect<DesktopServerExposureState>;
261 readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
262 readonly configureFromSettings: (input: {
263 readonly port: number;
264 }) => Effect.Effect<DesktopServerExposureState>;
265 readonly setMode: (
266 mode: DesktopServerExposureMode,
267 ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
268 readonly setTailscaleServeEnabled: (input: {
269 readonly enabled: boolean;
270 readonly port?: number;
271 }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
272 readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
273}
274
275export class DesktopServerExposure extends Context.Service<
276 DesktopServerExposure,
277 DesktopServerExposureShape
278>()("t3/desktop/ServerExposure") {}
279
280export interface DesktopNetworkInterfacesServiceShape {
281 readonly read: Effect.Effect<DesktopNetworkInterfaces>;
282}
283
284export class DesktopNetworkInterfacesService extends Context.Service<
285 DesktopNetworkInterfacesService,
286 DesktopNetworkInterfacesServiceShape
287>()("t3/desktop/ServerExposure/NetworkInterfaces") {}
288
289interface RuntimeState {
290 readonly requestedMode: DesktopServerExposureMode;
291 readonly mode: DesktopServerExposureMode;
292 readonly port: number;
293 readonly bindHost: string;
294 readonly localHttpUrl: string;
295 readonly localWsUrl: string;
296 readonly httpBaseUrl: URL;
297 readonly endpointUrl: Option.Option<string>;
298 readonly advertisedHost: Option.Option<string>;
299 readonly tailscaleServeEnabled: boolean;
300 readonly tailscaleServePort: number;
301}
302
303interface ResolvedRuntimeState {
304 readonly state: RuntimeState;
305 readonly unavailable: boolean;
306}
307
308const initialRuntimeState = (): RuntimeState =>
309 runtimeStateFromResolvedExposure({
310 requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
311 settings: DEFAULT_DESKTOP_SETTINGS,
312 exposure: resolveDesktopServerExposure({
313 mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
314 port: 0,
315 networkInterfaces: {},
316 }),
317 port: 0,
318 });
319
320const toContractState = (state: RuntimeState): DesktopServerExposureState => ({
321 mode: state.mode,
322 endpointUrl: Option.getOrNull(state.endpointUrl),
323 advertisedHost: Option.getOrNull(state.advertisedHost),
324 tailscaleServeEnabled: state.tailscaleServeEnabled,
325 tailscaleServePort: state.tailscaleServePort,
326});
327
328const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({
329 port: state.port,
330 bindHost: state.bindHost,
331 httpBaseUrl: state.httpBaseUrl,
332 tailscaleServeEnabled: state.tailscaleServeEnabled,
333 tailscaleServePort: state.tailscaleServePort,
334});
335
336const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({
337 mode: state.mode,
338 bindHost: state.bindHost,
339 localHttpUrl: state.localHttpUrl,
340 localWsUrl: state.localWsUrl,
341 endpointUrl: Option.getOrNull(state.endpointUrl),
342 advertisedHost: Option.getOrNull(state.advertisedHost),
343});
344
345function runtimeStateFromResolvedExposure(input: {
346 readonly requestedMode: DesktopServerExposureMode;
347 readonly settings: DesktopSettings;
348 readonly exposure: ResolvedDesktopServerExposure;
349 readonly port: number;
350}): RuntimeState {
351 return {
352 requestedMode: input.requestedMode,
353 mode: input.exposure.mode,
354 port: input.port,
355 bindHost: input.exposure.bindHost,
356 localHttpUrl: input.exposure.localHttpUrl,
357 localWsUrl: input.exposure.localWsUrl,
358 httpBaseUrl: new URL(input.exposure.localHttpUrl),
359 endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl),
360 advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost),
361 tailscaleServeEnabled: input.settings.tailscaleServeEnabled,
362 tailscaleServePort: input.settings.tailscaleServePort,
363 };
364}
365
366function resolveRuntimeState(input: {
367 readonly requestedMode: DesktopServerExposureMode;
368 readonly settings: DesktopSettings;
369 readonly port: number;
370 readonly networkInterfaces: DesktopNetworkInterfaces;
371 readonly advertisedHostOverride: Option.Option<string>;
372}): ResolvedRuntimeState {
373 const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
374 const requestedExposure = resolveDesktopServerExposure({
375 mode: input.requestedMode,
376 port: input.port,
377 networkInterfaces: input.networkInterfaces,
378 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
379 });
380 const unavailable =
381 input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null;
382 const exposure = unavailable
383 ? resolveDesktopServerExposure({
384 mode: "local-only",
385 port: input.port,
386 networkInterfaces: input.networkInterfaces,
387 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
388 })
389 : requestedExposure;
390
391 return {
392 state: runtimeStateFromResolvedExposure({
393 requestedMode: input.requestedMode,
394 settings: input.settings,
395 exposure,
396 port: input.port,
397 }),
398 unavailable,
399 };
400}
401
402const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
403 previous.port !== next.port ||
404 previous.bindHost !== next.bindHost ||
405 previous.localHttpUrl !== next.localHttpUrl;
406
407const make = Effect.gen(function* () {
408 const config = yield* DesktopConfig.DesktopConfig;
409 const networkInterfaces = yield* DesktopNetworkInterfacesService;
410 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
411 const httpClient = yield* HttpClient.HttpClient;
412 const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
413 const stateRef = yield* Ref.make(initialRuntimeState());
414
415 const readNetworkInterfaces = networkInterfaces.read;
416
417 const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
418 const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig));
419
420 const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")(
421 function* ({ port }: { readonly port: number }) {
422 yield* Effect.annotateCurrentSpan({ port });
423 const settings = yield* desktopSettings.get;
424 const currentNetworkInterfaces = yield* readNetworkInterfaces;
425 const resolved = resolveRuntimeState({
426 requestedMode: settings.serverExposureMode,
427 settings,
428 port,
429 networkInterfaces: currentNetworkInterfaces,
430 advertisedHostOverride: config.desktopLanHostOverride,
431 });
432 yield* Ref.set(stateRef, resolved.state);
433 return toContractState(resolved.state);
434 },
435 );
436
437 const setMode = Effect.fn("desktop.serverExposure.setMode")(function* (
438 mode: DesktopServerExposureMode,
439 ) {
440 yield* Effect.annotateCurrentSpan({ mode });
441 const previous = yield* Ref.get(stateRef);
442 const currentSettings = yield* desktopSettings.get;
443 const nextSettings = {
444 ...currentSettings,
445 serverExposureMode: mode,
446 };
447 const currentNetworkInterfaces = yield* readNetworkInterfaces;
448 const resolved = resolveRuntimeState({
449 requestedMode: mode,
450 settings: nextSettings,
451 port: previous.port,
452 networkInterfaces: currentNetworkInterfaces,
453 advertisedHostOverride: config.desktopLanHostOverride,
454 });
455
456 if (resolved.unavailable) {
457 return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port });
458 }
459
460 const change = yield* desktopSettings.setServerExposureMode(mode).pipe(
461 Effect.mapError(
462 (cause) =>
463 new DesktopServerExposurePersistenceError({
464 operation: "server-exposure-mode",
465 cause,
466 }),
467 ),
468 );
469
470 yield* Ref.set(stateRef, resolved.state);
471 return {
472 state: toContractState(resolved.state),
473 requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state),
474 };
475 });
476
477 const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")(
478 function* (input: { readonly enabled: boolean; readonly port?: number }) {
479 yield* Effect.annotateCurrentSpan({
480 enabled: input.enabled,
481 ...(input.port === undefined ? {} : { port: input.port }),
482 });
483 const result = yield* desktopSettings
484 .setTailscaleServe({
485 enabled: input.enabled,
486 port: Option.fromNullishOr(input.port),
487 })
488 .pipe(
489 Effect.mapError(
490 (cause) =>
491 new DesktopServerExposurePersistenceError({
492 operation: "tailscale-serve",
493 cause,
494 }),
495 ),
496 );
497
498 const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({
499 ...current,
500 tailscaleServeEnabled: result.settings.tailscaleServeEnabled,
501 tailscaleServePort: result.settings.tailscaleServePort,
502 }));
503
504 return {
505 state: toContractState(nextState),
506 requiresRelaunch: result.changed,
507 };
508 },
509 );
510
511 const getAdvertisedEndpoints = Effect.gen(function* () {
512 const state = yield* Ref.get(stateRef);
513 const currentNetworkInterfaces = yield* readNetworkInterfaces;
514 const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
515 port: state.port,
516 exposure: toResolvedExposure(state),
517 customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
518 });
519 const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
520 port: state.port,
521 serveEnabled: state.tailscaleServeEnabled,
522 servePort: state.tailscaleServePort,
523 networkInterfaces: currentNetworkInterfaces,
524 }).pipe(
525 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
526 Effect.provideService(HttpClient.HttpClient, httpClient),
527 );
528 return [...coreEndpoints, ...tailscaleEndpoints];
529 }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));
530
531 return DesktopServerExposure.of({
532 getState,
533 backendConfig,
534 configureFromSettings,
535 setMode,
536 setTailscaleServeEnabled,
537 getAdvertisedEndpoints,
538 });
539});
540
541export const layer = Layer.effect(DesktopServerExposure, make);
542
543export const networkInterfacesLayer = Layer.succeed(
544 DesktopNetworkInterfacesService,
545 DesktopNetworkInterfacesService.of({
546 read: Effect.sync(() => NodeOS.networkInterfaces()),
547 }),
548);
apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts created+142
...@@ -0,0 +1,142 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient } from "effect/unstable/http";
5import { ChildProcessSpawner } from "effect/unstable/process";
6
7import {
8 isTailscaleIpv4Address,
9 parseTailscaleMagicDnsName,
10 resolveTailscaleAdvertisedEndpoints,
11} from "./tailscaleEndpointProvider.ts";
12
13const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
14 Layer.succeed(
15 HttpClient.HttpClient,
16 HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
17 ),
18 Layer.succeed(
19 ChildProcessSpawner.ChildProcessSpawner,
20 ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")),
21 ),
22);
23
24describe("tailscale endpoint provider", () => {
25 it("detects Tailnet IPv4 addresses", () => {
26 assert.equal(isTailscaleIpv4Address("100.64.0.1"), true);
27 assert.equal(isTailscaleIpv4Address("100.127.255.254"), true);
28 assert.equal(isTailscaleIpv4Address("100.128.0.1"), false);
29 assert.equal(isTailscaleIpv4Address("192.168.1.44"), false);
30 });
31
32 it.effect("parses MagicDNS names from tailscale status", () =>
33 Effect.gen(function* () {
34 const dnsName = yield* parseTailscaleMagicDnsName(
35 `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
36 );
37 assert.equal(dnsName, "desktop.tail.ts.net");
38 assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
39 const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
40 assert.isTrue(malformed._tag === "Failure");
41 }),
42 );
43
44 it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () =>
45 Effect.gen(function* () {
46 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
47 port: 3773,
48 networkInterfaces: {
49 tailscale0: [
50 {
51 address: "100.100.100.100",
52 family: "IPv4",
53 internal: false,
54 netmask: "255.192.0.0",
55 cidr: "100.100.100.100/10",
56 mac: "00:00:00:00:00:00",
57 },
58 ],
59 },
60 statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
61 });
62 assert.deepEqual(endpoints, [
63 {
64 id: "tailscale-ip:http://100.100.100.100:3773",
65 label: "Tailscale IP",
66 provider: {
67 id: "tailscale",
68 label: "Tailscale",
69 kind: "private-network",
70 isAddon: true,
71 },
72 httpBaseUrl: "http://100.100.100.100:3773/",
73 wsBaseUrl: "ws://100.100.100.100:3773/",
74 reachability: "private-network",
75 compatibility: {
76 hostedHttpsApp: "mixed-content-blocked",
77 desktopApp: "compatible",
78 },
79 source: "desktop-addon",
80 status: "available",
81 description: "Reachable from devices on the same Tailnet.",
82 },
83 {
84 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
85 label: "Tailscale HTTPS",
86 provider: {
87 id: "tailscale",
88 label: "Tailscale",
89 kind: "private-network",
90 isAddon: true,
91 },
92 httpBaseUrl: "https://desktop.tail.ts.net/",
93 wsBaseUrl: "wss://desktop.tail.ts.net/",
94 reachability: "private-network",
95 compatibility: {
96 hostedHttpsApp: "requires-configuration",
97 desktopApp: "compatible",
98 },
99 source: "desktop-addon",
100 status: "unavailable",
101 description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
102 },
103 ]);
104 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
105 );
106
107 it.effect(
108 "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",
109 () =>
110 Effect.gen(function* () {
111 const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
112 port: 3773,
113 networkInterfaces: {},
114 statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
115 serveEnabled: true,
116 probe: () => Effect.succeed(true),
117 });
118 assert.deepEqual(endpoints, [
119 {
120 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
121 label: "Tailscale HTTPS",
122 provider: {
123 id: "tailscale",
124 label: "Tailscale",
125 kind: "private-network",
126 isAddon: true,
127 },
128 httpBaseUrl: "https://desktop.tail.ts.net/",
129 wsBaseUrl: "wss://desktop.tail.ts.net/",
130 reachability: "private-network",
131 compatibility: {
132 hostedHttpsApp: "compatible",
133 desktopApp: "compatible",
134 },
135 source: "desktop-addon",
136 status: "available",
137 description: "HTTPS endpoint served by Tailscale Serve.",
138 },
139 ]);
140 }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
141 );
142});
apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts created+138
...@@ -0,0 +1,138 @@
1import { createAdvertisedEndpoint } from "@t3tools/client-runtime";
2import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
3import {
4 buildTailscaleHttpsBaseUrl,
5 isTailscaleIpv4Address,
6 parseTailscaleMagicDnsName,
7 probeTailscaleHttpsEndpoint,
8 readTailscaleStatus,
9} from "@t3tools/tailscale";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import { HttpClient } from "effect/unstable/http";
13import { ChildProcessSpawner } from "effect/unstable/process";
14
15import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
16
17export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
18
19const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
20 id: "tailscale",
21 label: "Tailscale",
22 kind: "private-network",
23 isAddon: true,
24};
25
26function resolveTailscaleIpAdvertisedEndpoints(input: {
27 readonly port: number;
28 readonly networkInterfaces: DesktopNetworkInterfaces;
29}): readonly AdvertisedEndpoint[] {
30 const seen = new Set<string>();
31 const endpoints: AdvertisedEndpoint[] = [];
32
33 for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
34 if (!interfaceAddresses) continue;
35
36 for (const address of interfaceAddresses) {
37 if (address.internal) continue;
38 if (address.family !== "IPv4") continue;
39 if (!isTailscaleIpv4Address(address.address)) continue;
40 if (seen.has(address.address)) continue;
41 seen.add(address.address);
42
43 endpoints.push(
44 createAdvertisedEndpoint({
45 provider: TAILSCALE_ENDPOINT_PROVIDER,
46 source: "desktop-addon",
47 id: `tailscale-ip:http://${address.address}:${input.port}`,
48 label: "Tailscale IP",
49 httpBaseUrl: `http://${address.address}:${input.port}`,
50 reachability: "private-network",
51 status: "available",
52 description: "Reachable from devices on the same Tailnet.",
53 }),
54 );
55 }
56 }
57
58 return endpoints;
59}
60
61const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
62 "resolveTailscaleMagicDnsAdvertisedEndpoint",
63)(function* (input: {
64 readonly dnsName: string | null;
65 readonly serveEnabled: boolean;
66 readonly servePort?: number;
67 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
68}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
69 if (!input.dnsName) {
70 return Option.none();
71 }
72
73 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
74 magicDnsName: input.dnsName,
75 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
76 });
77 const probe =
78 input.probe?.(httpBaseUrl) ??
79 probeTailscaleHttpsEndpoint({
80 baseUrl: httpBaseUrl,
81 });
82 const isReachable = input.serveEnabled ? yield* probe : false;
83
84 return Option.some(
85 createAdvertisedEndpoint({
86 provider: TAILSCALE_ENDPOINT_PROVIDER,
87 source: "desktop-addon",
88 id: `tailscale-magicdns:${httpBaseUrl}`,
89 label: "Tailscale HTTPS",
90 httpBaseUrl,
91 reachability: "private-network",
92 hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
93 status: isReachable ? "available" : "unavailable",
94 description: isReachable
95 ? "HTTPS endpoint served by Tailscale Serve."
96 : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
97 }),
98 );
99});
100
101export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")(
102 function* (input: {
103 readonly port: number;
104 readonly serveEnabled?: boolean;
105 readonly servePort?: number;
106 readonly networkInterfaces: DesktopNetworkInterfaces;
107 readonly statusJson?: string | null;
108 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
109 }): Effect.fn.Return<
110 readonly AdvertisedEndpoint[],
111 never,
112 ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
113 > {
114 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
115 const dnsName =
116 input.statusJson === undefined
117 ? yield* readTailscaleStatus.pipe(
118 Effect.map((status) => status.magicDnsName),
119 Effect.catch(() => Effect.succeed(null)),
120 )
121 : input.statusJson
122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123 Effect.catch(() => Effect.succeed(null)),
124 )
125 : null;
126 const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
127 dnsName,
128 serveEnabled: input.serveEnabled === true,
129 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
130 ...(input.probe === undefined ? {} : { probe: input.probe }),
131 });
132
133 return Option.match(magicDnsEndpoint, {
134 onNone: () => ipEndpoints,
135 onSome: (endpoint) => [...ipEndpoints, endpoint],
136 });
137 },
138);
apps/desktop/src/serverListeningDetector.test.ts deleted-31
...@@ -1,31 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { ServerListeningDetector } from "./serverListeningDetector.ts";
4
5describe("ServerListeningDetector", () => {
6 it("resolves when the server logs the listening line", async () => {
7 const detector = new ServerListeningDetector();
8
9 detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n");
10
11 await expect(detector.promise).resolves.toBeUndefined();
12 });
13
14 it("resolves when the listening line arrives across multiple chunks", async () => {
15 const detector = new ServerListeningDetector();
16
17 detector.push("[01:23:30.571] INFO (#148): Listen");
18 detector.push("ing on http://0.0.0.0:7011\n");
19
20 await expect(detector.promise).resolves.toBeUndefined();
21 });
22
23 it("rejects when the server exits before logging readiness", async () => {
24 const detector = new ServerListeningDetector();
25 const error = new Error("server exited");
26
27 detector.fail(error);
28
29 await expect(detector.promise).rejects.toBe(error);
30 });
31});
apps/desktop/src/serverListeningDetector.ts deleted-56
...@@ -1,56 +0,0 @@
1const LISTENING_LOG_FRAGMENT = "Listening on http://";
2const MAX_BUFFER_CHARS = 8_192;
3
4export class ServerListeningDetector {
5 private buffer = "";
6 private settled = false;
7 private readonly resolvePromise: () => void;
8 private readonly rejectPromise: (error: unknown) => void;
9 readonly promise: Promise<void>;
10
11 constructor() {
12 let resolvePromise: (() => void) | null = null;
13 let rejectPromise: ((error: unknown) => void) | null = null;
14
15 this.promise = new Promise<void>((resolve, reject) => {
16 resolvePromise = resolve;
17 rejectPromise = reject;
18 });
19
20 this.resolvePromise = () => {
21 if (this.settled) {
22 return;
23 }
24 this.settled = true;
25 resolvePromise?.();
26 };
27 this.rejectPromise = (error) => {
28 if (this.settled) {
29 return;
30 }
31 this.settled = true;
32 rejectPromise?.(error);
33 };
34 }
35
36 push(chunk: unknown): void {
37 if (this.settled) {
38 return;
39 }
40
41 const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
42 this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`;
43 if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) {
44 this.resolvePromise();
45 return;
46 }
47
48 if (this.buffer.length > MAX_BUFFER_CHARS) {
49 this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS);
50 }
51 }
52
53 fail(error: unknown): void {
54 this.rejectPromise(error);
55 }
56}
apps/desktop/src/settings/DesktopAppSettings.test.ts created+284
...@@ -0,0 +1,284 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8
9import * as DesktopConfig from "../app/DesktopConfig.ts";
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import {
12 DEFAULT_DESKTOP_SETTINGS,
13 resolveDefaultDesktopSettings,
14 type DesktopSettings as DesktopSettingsValue,
15} from "./DesktopAppSettings.ts";
16import * as DesktopAppSettings from "./DesktopAppSettings.ts";
17
18const DesktopSettingsPatch = Schema.Struct({
19 serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])),
20 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
21 tailscaleServePort: Schema.optionalKey(Schema.Number),
22 updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])),
23 updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
24});
25
26const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
27const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
28
29function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") {
30 return DesktopEnvironment.layer({
31 dirname: "/repo/apps/desktop/src",
32 homeDirectory: baseDir,
33 platform: "darwin",
34 processArch: "x64",
35 appVersion,
36 appPath: "/repo",
37 isPackaged: true,
38 resourcesPath: "/missing/resources",
39 runningUnderArm64Translation: false,
40 }).pipe(
41 Layer.provide(
42 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
43 ),
44 );
45}
46
47const withSettings = <A, E, R>(
48 effect: Effect.Effect<
49 A,
50 E,
51 R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment
52 >,
53 options?: { readonly appVersion?: string },
54) =>
55 Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;
57 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
58 prefix: "t3-desktop-settings-test-",
59 });
60 return yield* effect.pipe(
61 Effect.provide(
62 DesktopAppSettings.layer.pipe(
63 Layer.provideMerge(makeEnvironmentLayer(baseDir, options?.appVersion)),
64 Layer.provideMerge(NodeServices.layer),
65 ),
66 ),
67 );
68 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
69
70function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) {
71 return Effect.gen(function* () {
72 const environment = yield* DesktopEnvironment.DesktopEnvironment;
73 const fileSystem = yield* FileSystem.FileSystem;
74 const encoded = yield* encodeDesktopSettingsPatch(patch);
75 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
76 yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`);
77 });
78}
79
80describe("DesktopSettings", () => {
81 it.effect("loads defaults when no settings file exists", () =>
82 withSettings(
83 Effect.gen(function* () {
84 const settings = yield* DesktopAppSettings.DesktopAppSettings;
85 assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);
86 assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS);
87 }),
88 ),
89 );
90
91 it("defaults packaged nightly builds to the nightly update channel", () => {
92 assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), {
93 serverExposureMode: "local-only",
94 tailscaleServeEnabled: false,
95 tailscaleServePort: 443,
96 updateChannel: "nightly",
97 updateChannelConfiguredByUser: false,
98 } satisfies DesktopSettingsValue);
99 });
100
101 it.effect("loads persisted settings and applies semantic updates", () =>
102 withSettings(
103 Effect.gen(function* () {
104 const settings = yield* DesktopAppSettings.DesktopAppSettings;
105 yield* writeSettingsPatch({
106 serverExposureMode: "network-accessible",
107 tailscaleServeEnabled: true,
108 tailscaleServePort: 8443,
109 updateChannel: "latest",
110 updateChannelConfiguredByUser: true,
111 });
112
113 assert.deepEqual(yield* settings.load, {
114 serverExposureMode: "network-accessible",
115 tailscaleServeEnabled: true,
116 tailscaleServePort: 8443,
117 updateChannel: "latest",
118 updateChannelConfiguredByUser: true,
119 } satisfies DesktopSettingsValue);
120
121 const exposure = yield* settings.setServerExposureMode("local-only");
122 assert.isTrue(exposure.changed);
123 assert.equal(exposure.settings.serverExposureMode, "local-only");
124
125 const tailscale = yield* settings.setTailscaleServe({
126 enabled: true,
127 port: Option.some(9443),
128 });
129 assert.isTrue(tailscale.changed);
130 assert.equal(tailscale.settings.tailscaleServePort, 9443);
131
132 const updateChannel = yield* settings.setUpdateChannel("nightly");
133 assert.isTrue(updateChannel.changed);
134 assert.equal(updateChannel.settings.updateChannel, "nightly");
135 assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true);
136 }),
137 ),
138 );
139
140 it.effect("does not persist no-op semantic updates", () =>
141 withSettings(
142 Effect.gen(function* () {
143 const settings = yield* DesktopAppSettings.DesktopAppSettings;
144
145 const exposure = yield* settings.setServerExposureMode("local-only");
146 assert.isFalse(exposure.changed);
147
148 const tailscale = yield* settings.setTailscaleServe({
149 enabled: false,
150 port: Option.none(),
151 });
152 assert.isFalse(tailscale.changed);
153
154 const updateChannel = yield* settings.setUpdateChannel("latest");
155 assert.isFalse(updateChannel.changed);
156 assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false);
157 }),
158 ),
159 );
160
161 it.effect("falls back to defaults when the settings file is malformed", () =>
162 withSettings(
163 Effect.gen(function* () {
164 const environment = yield* DesktopEnvironment.DesktopEnvironment;
165 const fileSystem = yield* FileSystem.FileSystem;
166 const settings = yield* DesktopAppSettings.DesktopAppSettings;
167 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
168 yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json");
169
170 assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);
171 }),
172 ),
173 );
174
175 it.effect("loads lenient persisted desktop settings JSON", () =>
176 withSettings(
177 Effect.gen(function* () {
178 const environment = yield* DesktopEnvironment.DesktopEnvironment;
179 const fileSystem = yield* FileSystem.FileSystem;
180 const settings = yield* DesktopAppSettings.DesktopAppSettings;
181 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
182 yield* fileSystem.writeFileString(
183 environment.desktopSettingsPath,
184 `{
185 // JSONC-style comments and trailing commas match server settings parsing.
186 "serverExposureMode": "network-accessible",
187 "tailscaleServeEnabled": true,
188 "tailscaleServePort": 8443,
189 }\n`,
190 );
191
192 assert.deepEqual(yield* settings.load, {
193 serverExposureMode: "network-accessible",
194 tailscaleServeEnabled: true,
195 tailscaleServePort: 8443,
196 updateChannel: "latest",
197 updateChannelConfiguredByUser: false,
198 } satisfies DesktopSettingsValue);
199 }),
200 ),
201 );
202
203 it.effect("persists sparse desktop settings documents", () =>
204 withSettings(
205 Effect.gen(function* () {
206 const environment = yield* DesktopEnvironment.DesktopEnvironment;
207 const fileSystem = yield* FileSystem.FileSystem;
208 const settings = yield* DesktopAppSettings.DesktopAppSettings;
209
210 yield* settings.setServerExposureMode("network-accessible");
211
212 const persisted = yield* decodeDesktopSettingsPatch(
213 yield* fileSystem.readFileString(environment.desktopSettingsPath),
214 );
215 assert.deepEqual(persisted, {
216 serverExposureMode: "network-accessible",
217 } satisfies typeof DesktopSettingsPatch.Type);
218 }),
219 ),
220 );
221
222 it.effect("migrates legacy implicit update channels to the runtime default", () =>
223 withSettings(
224 Effect.gen(function* () {
225 const settings = yield* DesktopAppSettings.DesktopAppSettings;
226 yield* writeSettingsPatch({
227 serverExposureMode: "local-only",
228 updateChannel: "latest",
229 });
230
231 assert.deepEqual(yield* settings.load, {
232 serverExposureMode: "local-only",
233 tailscaleServeEnabled: false,
234 tailscaleServePort: 443,
235 updateChannel: "nightly",
236 updateChannelConfiguredByUser: false,
237 } satisfies DesktopSettingsValue);
238 }),
239 { appVersion: "0.0.17-nightly.20260415.1" },
240 ),
241 );
242
243 it.effect("preserves explicit stable update channel on nightly builds", () =>
244 withSettings(
245 Effect.gen(function* () {
246 const settings = yield* DesktopAppSettings.DesktopAppSettings;
247 yield* writeSettingsPatch({
248 serverExposureMode: "local-only",
249 updateChannel: "latest",
250 updateChannelConfiguredByUser: true,
251 });
252
253 assert.deepEqual(yield* settings.load, {
254 serverExposureMode: "local-only",
255 tailscaleServeEnabled: false,
256 tailscaleServePort: 443,
257 updateChannel: "latest",
258 updateChannelConfiguredByUser: true,
259 } satisfies DesktopSettingsValue);
260 }),
261 { appVersion: "0.0.17-nightly.20260415.1" },
262 ),
263 );
264
265 it.effect("normalizes invalid persisted Tailscale Serve ports", () =>
266 withSettings(
267 Effect.gen(function* () {
268 const settings = yield* DesktopAppSettings.DesktopAppSettings;
269 yield* writeSettingsPatch({
270 tailscaleServeEnabled: true,
271 tailscaleServePort: 0,
272 });
273
274 assert.deepEqual(yield* settings.load, {
275 serverExposureMode: "local-only",
276 tailscaleServeEnabled: true,
277 tailscaleServePort: 443,
278 updateChannel: "latest",
279 updateChannelConfiguredByUser: false,
280 } satisfies DesktopSettingsValue);
281 }),
282 ),
283 );
284});
apps/desktop/src/settings/DesktopAppSettings.ts created+318
...@@ -0,0 +1,318 @@
1import {
2 DesktopServerExposureModeSchema,
3 DesktopUpdateChannelSchema,
4 type DesktopServerExposureMode,
5 type DesktopUpdateChannel,
6} from "@t3tools/contracts";
7import { fromLenientJson } from "@t3tools/shared/schemaJson";
8import * as Context from "effect/Context";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as PlatformError from "effect/PlatformError";
16import * as Random from "effect/Random";
17import * as Schema from "effect/Schema";
18import * as SynchronizedRef from "effect/SynchronizedRef";
19
20import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
21import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
22
23export interface DesktopSettings {
24 readonly serverExposureMode: DesktopServerExposureMode;
25 readonly tailscaleServeEnabled: boolean;
26 readonly tailscaleServePort: number;
27 readonly updateChannel: DesktopUpdateChannel;
28 readonly updateChannelConfiguredByUser: boolean;
29}
30
31export interface DesktopSettingsChange {
32 readonly settings: DesktopSettings;
33 readonly changed: boolean;
34}
35
36export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
37
38export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
39 serverExposureMode: "local-only",
40 tailscaleServeEnabled: false,
41 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
42 updateChannel: "latest",
43 updateChannelConfiguredByUser: false,
44};
45
46const DesktopSettingsDocument = Schema.Struct({
47 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),
48 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
49 tailscaleServePort: Schema.optionalKey(Schema.Number),
50 updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema),
51 updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
52});
53
54type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type;
55type Mutable<T> = { -readonly [K in keyof T]: T[K] };
56
57const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument);
58const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson);
59const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson);
60
61const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({
62 settings,
63 changed,
64});
65
66export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{
67 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
68}> {
69 override get message() {
70 return `Failed to write desktop settings: ${this.cause.message}`;
71 }
72}
73
74export interface DesktopAppSettingsShape {
75 readonly load: Effect.Effect<DesktopSettings>;
76 readonly get: Effect.Effect<DesktopSettings>;
77 readonly setServerExposureMode: (
78 mode: DesktopServerExposureMode,
79 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
80 readonly setTailscaleServe: (input: {
81 readonly enabled: boolean;
82 readonly port: Option.Option<number>;
83 }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
84 readonly setUpdateChannel: (
85 channel: DesktopUpdateChannel,
86 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
87}
88
89export class DesktopAppSettings extends Context.Service<
90 DesktopAppSettings,
91 DesktopAppSettingsShape
92>()("t3/desktop/AppSettings") {}
93
94export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
95 return {
96 ...DEFAULT_DESKTOP_SETTINGS,
97 updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
98 };
99}
100
101function normalizeTailscaleServePort(value: unknown): number {
102 return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
103 ? value
104 : DEFAULT_TAILSCALE_SERVE_PORT;
105}
106
107function normalizeDesktopSettingsDocument(
108 parsed: DesktopSettingsDocument,
109 appVersion: string,
110): DesktopSettings {
111 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
112 const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel);
113 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
114 const updateChannelConfiguredByUser =
115 parsed.updateChannelConfiguredByUser === true ||
116 (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly"));
117
118 return {
119 serverExposureMode:
120 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
121 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
122 tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
123 updateChannel: updateChannelConfiguredByUser
124 ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel)
125 : defaultSettings.updateChannel,
126 updateChannelConfiguredByUser,
127 };
128}
129
130function toDesktopSettingsDocument(
131 settings: DesktopSettings,
132 defaults: DesktopSettings,
133): DesktopSettingsDocument {
134 const document: Mutable<DesktopSettingsDocument> = {};
135
136 if (settings.serverExposureMode !== defaults.serverExposureMode) {
137 document.serverExposureMode = settings.serverExposureMode;
138 }
139 if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) {
140 document.tailscaleServeEnabled = settings.tailscaleServeEnabled;
141 }
142 if (settings.tailscaleServePort !== defaults.tailscaleServePort) {
143 document.tailscaleServePort = settings.tailscaleServePort;
144 }
145 if (settings.updateChannel !== defaults.updateChannel) {
146 document.updateChannel = settings.updateChannel;
147 }
148 if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) {
149 document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser;
150 }
151
152 return document;
153}
154
155function setServerExposureMode(
156 settings: DesktopSettings,
157 requestedMode: DesktopServerExposureMode,
158): DesktopSettings {
159 return settings.serverExposureMode === requestedMode
160 ? settings
161 : {
162 ...settings,
163 serverExposureMode: requestedMode,
164 };
165}
166
167function setTailscaleServe(
168 settings: DesktopSettings,
169 input: { readonly enabled: boolean; readonly port: Option.Option<number> },
170): DesktopSettings {
171 const port = Option.match(input.port, {
172 onNone: () => settings.tailscaleServePort,
173 onSome: normalizeTailscaleServePort,
174 });
175 return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
176 ? settings
177 : {
178 ...settings,
179 tailscaleServeEnabled: input.enabled,
180 tailscaleServePort: port,
181 };
182}
183
184function setUpdateChannel(
185 settings: DesktopSettings,
186 requestedChannel: DesktopUpdateChannel,
187): DesktopSettings {
188 return settings.updateChannel === requestedChannel
189 ? settings
190 : {
191 ...settings,
192 updateChannel: requestedChannel,
193 updateChannelConfiguredByUser: true,
194 };
195}
196
197function readSettings(
198 fileSystem: FileSystem.FileSystem,
199 settingsPath: string,
200 appVersion: string,
201): Effect.Effect<DesktopSettings> {
202 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
203
204 return fileSystem.readFileString(settingsPath).pipe(
205 Effect.option,
206 Effect.flatMap(
207 Option.match({
208 onNone: () => Effect.succeed(defaultSettings),
209 onSome: (raw) =>
210 decodeDesktopSettingsJson(raw).pipe(
211 Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)),
212 Effect.catch(() => Effect.succeed(defaultSettings)),
213 ),
214 }),
215 ),
216 );
217}
218
219const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: {
220 readonly fileSystem: FileSystem.FileSystem;
221 readonly path: Path.Path;
222 readonly settingsPath: string;
223 readonly settings: DesktopSettings;
224 readonly defaultSettings: DesktopSettings;
225}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
226 const directory = input.path.dirname(input.settingsPath);
227 const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
228 const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
229 const encoded = yield* encodeDesktopSettingsJson(
230 toDesktopSettingsDocument(input.settings, input.defaultSettings),
231 );
232 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
233 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
234 yield* input.fileSystem.rename(tempPath, input.settingsPath);
235});
236
237export const layer = Layer.effect(
238 DesktopAppSettings,
239 Effect.gen(function* () {
240 const environment = yield* DesktopEnvironment.DesktopEnvironment;
241 const fileSystem = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
244
245 const persist = (
246 update: (settings: DesktopSettings) => DesktopSettings,
247 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
248 SynchronizedRef.modifyEffect(settingsRef, (settings) => {
249 const nextSettings = update(settings);
250 if (nextSettings === settings) {
251 return Effect.succeed([settingsChange(settings, false), settings] as const);
252 }
253
254 return writeSettings({
255 fileSystem,
256 path,
257 settingsPath: environment.desktopSettingsPath,
258 settings: nextSettings,
259 defaultSettings: environment.defaultDesktopSettings,
260 }).pipe(
261 Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })),
262 Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
263 );
264 });
265
266 return DesktopAppSettings.of({
267 get: SynchronizedRef.get(settingsRef),
268 load: Effect.gen(function* () {
269 const settings = yield* readSettings(
270 fileSystem,
271 environment.desktopSettingsPath,
272 environment.appVersion,
273 );
274 return yield* SynchronizedRef.setAndGet(settingsRef, settings);
275 }).pipe(Effect.withSpan("desktop.settings.load")),
276 setServerExposureMode: (mode) =>
277 persist((settings) => setServerExposureMode(settings, mode)).pipe(
278 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),
279 ),
280 setTailscaleServe: (input) =>
281 persist((settings) => setTailscaleServe(settings, input)).pipe(
282 Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }),
283 ),
284 setUpdateChannel: (channel) =>
285 persist((settings) => setUpdateChannel(settings, channel)).pipe(
286 Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),
287 ),
288 });
289 }),
290);
291
292export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>
293 Layer.effect(
294 DesktopAppSettings,
295 Effect.gen(function* () {
296 const settingsRef = yield* SynchronizedRef.make(initialSettings);
297 const update = (f: (settings: DesktopSettings) => DesktopSettings) =>
298 SynchronizedRef.modify(settingsRef, (settings) => {
299 const nextSettings = f(settings);
300 return [
301 {
302 settings: nextSettings,
303 changed: nextSettings !== settings,
304 },
305 nextSettings,
306 ] as const;
307 });
308
309 return DesktopAppSettings.of({
310 get: SynchronizedRef.get(settingsRef),
311 load: SynchronizedRef.get(settingsRef),
312 setServerExposureMode: (mode) =>
313 update((settings) => setServerExposureMode(settings, mode)),
314 setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)),
315 setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)),
316 });
317 }),
318 );
apps/desktop/src/settings/DesktopClientSettings.test.ts created+185
...@@ -0,0 +1,185 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9
10import * as DesktopConfig from "../app/DesktopConfig.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as DesktopClientSettings from "./DesktopClientSettings.ts";
13
14const clientSettings: ClientSettings = {
15 autoOpenPlanSidebar: false,
16 confirmThreadArchive: true,
17 confirmThreadDelete: false,
18 dismissedProviderUpdateNotificationKeys: [],
19 diffIgnoreWhitespace: true,
20 diffWordWrap: true,
21 favorites: [],
22 providerModelPreferences: {},
23 sidebarProjectGroupingMode: "repository_path",
24 sidebarProjectGroupingOverrides: {
25 "environment-1:/tmp/project-a": "separate",
26 },
27 sidebarProjectSortOrder: "manual",
28 sidebarThreadSortOrder: "created_at",
29 sidebarThreadPreviewCount: 6,
30 timestampFormat: "24-hour",
31};
32
33const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema));
34const decodeRecordJson = Schema.decodeEffect(
35 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
36);
37
38function makeLayer(baseDir: string) {
39 const environmentLayer = DesktopEnvironment.layer({
40 dirname: "/repo/apps/desktop/src",
41 homeDirectory: baseDir,
42 platform: "darwin",
43 processArch: "x64",
44 appVersion: "1.2.3",
45 appPath: "/repo",
46 isPackaged: true,
47 resourcesPath: "/missing/resources",
48 runningUnderArm64Translation: false,
49 }).pipe(
50 Layer.provide(
51 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
52 ),
53 );
54
55 return DesktopClientSettings.layer.pipe(
56 Layer.provideMerge(environmentLayer),
57 Layer.provideMerge(NodeServices.layer),
58 );
59}
60
61const withClientSettings = <A, E, R>(
62 effect: Effect.Effect<A, E, R | DesktopClientSettings.DesktopClientSettings>,
63) =>
64 Effect.gen(function* () {
65 const fileSystem = yield* FileSystem.FileSystem;
66 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
67 prefix: "t3-desktop-client-settings-test-",
68 });
69 return yield* effect.pipe(Effect.provide(makeLayer(baseDir)));
70 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
71
72describe("DesktopClientSettings", () => {
73 it.effect("returns none when no client settings file exists", () =>
74 withClientSettings(
75 Effect.gen(function* () {
76 const settings = yield* DesktopClientSettings.DesktopClientSettings;
77 assert.isTrue(Option.isNone(yield* settings.get));
78 }),
79 ),
80 );
81
82 it.effect("persists and reloads client settings", () =>
83 withClientSettings(
84 Effect.gen(function* () {
85 const environment = yield* DesktopEnvironment.DesktopEnvironment;
86 const fileSystem = yield* FileSystem.FileSystem;
87 const settings = yield* DesktopClientSettings.DesktopClientSettings;
88 yield* settings.set(clientSettings);
89
90 assert.deepEqual(yield* settings.get, Option.some(clientSettings));
91 assert.deepEqual(
92 yield* decodeClientSettingsJson(
93 yield* fileSystem.readFileString(environment.clientSettingsPath),
94 ),
95 clientSettings,
96 );
97 assert.isFalse(
98 Object.hasOwn(
99 yield* decodeRecordJson(
100 yield* fileSystem.readFileString(environment.clientSettingsPath),
101 ),
102 "settings",
103 ),
104 );
105 }),
106 ),
107 );
108
109 it.effect("loads lenient direct client settings documents", () =>
110 withClientSettings(
111 Effect.gen(function* () {
112 const environment = yield* DesktopEnvironment.DesktopEnvironment;
113 const fileSystem = yield* FileSystem.FileSystem;
114 const settings = yield* DesktopClientSettings.DesktopClientSettings;
115 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
116 yield* fileSystem.writeFileString(
117 environment.clientSettingsPath,
118 `{
119 // Matches server settings parsing.
120 "timestampFormat": "24-hour",
121 }\n`,
122 );
123
124 const persisted = yield* settings.get;
125 assert.isTrue(Option.isSome(persisted));
126 if (Option.isSome(persisted)) {
127 assert.equal(persisted.value.timestampFormat, "24-hour");
128 }
129 }),
130 ),
131 );
132
133 it.effect("loads legacy wrapped client settings documents", () =>
134 withClientSettings(
135 Effect.gen(function* () {
136 const environment = yield* DesktopEnvironment.DesktopEnvironment;
137 const fileSystem = yield* FileSystem.FileSystem;
138 const settings = yield* DesktopClientSettings.DesktopClientSettings;
139 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
140 yield* fileSystem.writeFileString(
141 environment.clientSettingsPath,
142 `{
143 "settings": {
144 "timestampFormat": "12-hour"
145 }
146 }\n`,
147 );
148
149 const persisted = yield* settings.get;
150 assert.isTrue(Option.isSome(persisted));
151 if (Option.isSome(persisted)) {
152 assert.equal(persisted.value.timestampFormat, "12-hour");
153 }
154 }),
155 ),
156 );
157
158 it.effect("loads defaults from empty client settings documents", () =>
159 withClientSettings(
160 Effect.gen(function* () {
161 const environment = yield* DesktopEnvironment.DesktopEnvironment;
162 const fileSystem = yield* FileSystem.FileSystem;
163 const settings = yield* DesktopClientSettings.DesktopClientSettings;
164 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
165 yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n");
166
167 assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}")));
168 }),
169 ),
170 );
171
172 it.effect("treats malformed client settings documents as absent", () =>
173 withClientSettings(
174 Effect.gen(function* () {
175 const environment = yield* DesktopEnvironment.DesktopEnvironment;
176 const fileSystem = yield* FileSystem.FileSystem;
177 const settings = yield* DesktopClientSettings.DesktopClientSettings;
178 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
179 yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json");
180
181 assert.isTrue(Option.isNone(yield* settings.get));
182 }),
183 ),
184 );
185});
apps/desktop/src/settings/DesktopClientSettings.ts created+118
...@@ -0,0 +1,118 @@
1import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";
11import * as Random from "effect/Random";
12import * as Schema from "effect/Schema";
13import * as Ref from "effect/Ref";
14
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16
17const ClientSettingsDocumentSchema = Schema.Struct({
18 settings: ClientSettingsSchema,
19});
20
21const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);
22const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema);
23const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> =>
24 Schema.decodeEffect(LegacyClientSettingsDocumentJson)(raw).pipe(
25 Effect.map((document) => document.settings),
26 Effect.catch(() => Schema.decodeEffect(ClientSettingsJson)(raw)),
27 );
28const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);
29
30export class DesktopClientSettingsWriteError extends Data.TaggedError(
31 "DesktopClientSettingsWriteError",
32)<{
33 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
34}> {
35 override get message() {
36 return `Failed to write desktop client settings: ${this.cause.message}`;
37 }
38}
39
40export interface DesktopClientSettingsShape {
41 readonly get: Effect.Effect<Option.Option<ClientSettings>>;
42 readonly set: (settings: ClientSettings) => Effect.Effect<void, DesktopClientSettingsWriteError>;
43}
44
45export class DesktopClientSettings extends Context.Service<
46 DesktopClientSettings,
47 DesktopClientSettingsShape
48>()("t3/desktop/ClientSettings") {}
49
50const readClientSettings = (
51 fileSystem: FileSystem.FileSystem,
52 settingsPath: string,
53): Effect.Effect<Option.Option<ClientSettings>> =>
54 fileSystem.readFileString(settingsPath).pipe(
55 Effect.option,
56 Effect.flatMap(
57 Option.match({
58 onNone: () => Effect.succeed(Option.none<ClientSettings>()),
59 onSome: (raw) =>
60 decodeClientSettingsJson(raw).pipe(
61 Effect.map((settings) => Option.some(settings)),
62 Effect.catch(() => Effect.succeed(Option.none<ClientSettings>())),
63 ),
64 }),
65 ),
66 );
67
68const writeClientSettings = Effect.fnUntraced(function* (input: {
69 readonly fileSystem: FileSystem.FileSystem;
70 readonly path: Path.Path;
71 readonly settingsPath: string;
72 readonly settings: ClientSettings;
73}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
74 const directory = input.path.dirname(input.settingsPath);
75 const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
76 const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
77 const encoded = yield* encodeClientSettingsJson(input.settings);
78 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
79 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
80 yield* input.fileSystem.rename(tempPath, input.settingsPath);
81});
82
83export const layer = Layer.effect(
84 DesktopClientSettings,
85 Effect.gen(function* () {
86 const environment = yield* DesktopEnvironment.DesktopEnvironment;
87 const fileSystem = yield* FileSystem.FileSystem;
88 const path = yield* Path.Path;
89
90 return DesktopClientSettings.of({
91 get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
92 Effect.withSpan("desktop.clientSettings.get"),
93 ),
94 set: (settings) =>
95 writeClientSettings({
96 fileSystem,
97 path,
98 settingsPath: environment.clientSettingsPath,
99 settings,
100 }).pipe(
101 Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })),
102 Effect.withSpan("desktop.clientSettings.set"),
103 ),
104 });
105 }),
106);
107
108export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>
109 Layer.effect(
110 DesktopClientSettings,
111 Effect.gen(function* () {
112 const settingsRef = yield* Ref.make(initialSettings);
113 return DesktopClientSettings.of({
114 get: Ref.get(settingsRef),
115 set: (settings) => Ref.set(settingsRef, Option.some(settings)),
116 });
117 }),
118 );
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts created+344
...@@ -0,0 +1,344 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9
10import * as DesktopConfig from "../app/DesktopConfig.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
13import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts";
14
15const textDecoder = new TextDecoder();
16const textEncoder = new TextEncoder();
17
18const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
19 environmentId: EnvironmentId.make("environment-1"),
20 label: "Remote environment",
21 httpBaseUrl: "https://remote.example.com/",
22 wsBaseUrl: "wss://remote.example.com/",
23 createdAt: "2026-04-09T00:00:00.000Z",
24 lastConnectedAt: "2026-04-09T01:00:00.000Z",
25 desktopSsh: {
26 alias: "devbox",
27 hostname: "devbox.example.com",
28 username: "julius",
29 port: 22,
30 },
31};
32
33const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
34 version: Schema.Number,
35 records: Schema.Array(Schema.Unknown),
36});
37const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
38 Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe),
39);
40
41function makeSafeStorageLayer(input: {
42 readonly available: boolean;
43 readonly availabilityError?: unknown;
44 readonly encryptError?: unknown;
45 readonly decryptError?: unknown;
46}) {
47 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
48 isEncryptionAvailable:
49 input.availabilityError === undefined
50 ? Effect.succeed(input.available)
51 : Effect.fail(
52 new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({
53 cause: input.availabilityError,
54 }),
55 ),
56 encryptString: (value) =>
57 input.encryptError === undefined
58 ? Effect.succeed(textEncoder.encode(`enc:${value}`))
59 : Effect.fail(
60 new ElectronSafeStorage.ElectronSafeStorageEncryptError({
61 cause: input.encryptError,
62 }),
63 ),
64 decryptString: (value) => {
65 if (input.decryptError !== undefined) {
66 return Effect.fail(
67 new ElectronSafeStorage.ElectronSafeStorageDecryptError({
68 cause: input.decryptError,
69 }),
70 );
71 }
72
73 const decoded = textDecoder.decode(value);
74 if (!decoded.startsWith("enc:")) {
75 return Effect.fail(
76 new ElectronSafeStorage.ElectronSafeStorageDecryptError({
77 cause: new Error("invalid secret"),
78 }),
79 );
80 }
81 return Effect.succeed(decoded.slice("enc:".length));
82 },
83 } satisfies ElectronSafeStorage.ElectronSafeStorageShape);
84}
85
86function makeLayer(
87 baseDir: string,
88 options?: {
89 readonly availableSecretStorage?: boolean;
90 readonly availabilityError?: unknown;
91 readonly encryptError?: unknown;
92 readonly decryptError?: unknown;
93 },
94) {
95 const environmentLayer = DesktopEnvironment.layer({
96 dirname: "/repo/apps/desktop/src",
97 homeDirectory: baseDir,
98 platform: "darwin",
99 processArch: "x64",
100 appVersion: "1.2.3",
101 appPath: "/repo",
102 isPackaged: true,
103 resourcesPath: "/missing/resources",
104 runningUnderArm64Translation: false,
105 }).pipe(
106 Layer.provide(
107 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
108 ),
109 );
110
111 return DesktopSavedEnvironments.layer.pipe(
112 Layer.provideMerge(environmentLayer),
113 Layer.provideMerge(
114 makeSafeStorageLayer({
115 available: options?.availableSecretStorage ?? true,
116 availabilityError: options?.availabilityError,
117 encryptError: options?.encryptError,
118 decryptError: options?.decryptError,
119 }),
120 ),
121 Layer.provideMerge(NodeServices.layer),
122 );
123}
124
125const withSavedEnvironments = <A, E, R>(
126 effect: Effect.Effect<A, E, R | DesktopSavedEnvironments.DesktopSavedEnvironments>,
127 options?: {
128 readonly availableSecretStorage?: boolean;
129 readonly availabilityError?: unknown;
130 readonly encryptError?: unknown;
131 readonly decryptError?: unknown;
132 },
133) =>
134 Effect.gen(function* () {
135 const fileSystem = yield* FileSystem.FileSystem;
136 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
137 prefix: "t3-desktop-saved-environments-test-",
138 });
139 return yield* effect.pipe(Effect.provide(makeLayer(baseDir, options)));
140 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
141
142describe("DesktopSavedEnvironments", () => {
143 it.effect("persists and reloads saved environment metadata", () =>
144 withSavedEnvironments(
145 Effect.gen(function* () {
146 const environment = yield* DesktopEnvironment.DesktopEnvironment;
147 const fileSystem = yield* FileSystem.FileSystem;
148 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
149 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
150
151 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
152 const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe(
153 yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath),
154 );
155 assert.equal(persisted.version, 1);
156 assert.lengthOf(persisted.records, 1);
157 }),
158 ),
159 );
160
161 it.effect("loads lenient saved environment registry documents", () =>
162 withSavedEnvironments(
163 Effect.gen(function* () {
164 const environment = yield* DesktopEnvironment.DesktopEnvironment;
165 const fileSystem = yield* FileSystem.FileSystem;
166 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
167 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
168 yield* fileSystem.writeFileString(
169 environment.savedEnvironmentRegistryPath,
170 `{
171 // Same optional envelope shape as browser saved environments.
172 "version": 1,
173 "records": [
174 {
175 "environmentId": "${savedRegistryRecord.environmentId}",
176 "label": "Remote environment",
177 "httpBaseUrl": "https://remote.example.com/",
178 "wsBaseUrl": "wss://remote.example.com/",
179 "createdAt": "2026-04-09T00:00:00.000Z",
180 "lastConnectedAt": "2026-04-09T01:00:00.000Z",
181 "desktopSsh": {
182 "alias": "devbox",
183 "hostname": "devbox.example.com",
184 "username": "julius",
185 "port": 22,
186 },
187 },
188 ],
189 }\n`,
190 );
191
192 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
193 }),
194 ),
195 );
196
197 it.effect("persists encrypted saved environment secrets when encryption is available", () =>
198 withSavedEnvironments(
199 Effect.gen(function* () {
200 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
201 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
202
203 assert.isTrue(
204 yield* savedEnvironments.setSecret({
205 environmentId: savedRegistryRecord.environmentId,
206 secret: "bearer-token",
207 }),
208 );
209
210 assert.deepEqual(
211 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
212 Option.some("bearer-token"),
213 );
214 }),
215 ),
216 );
217
218 it.effect("returns false when writing secrets while encryption is unavailable", () =>
219 withSavedEnvironments(
220 Effect.gen(function* () {
221 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
222 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
223
224 assert.isFalse(
225 yield* savedEnvironments.setSecret({
226 environmentId: savedRegistryRecord.environmentId,
227 secret: "next-token",
228 }),
229 );
230 }),
231 { availableSecretStorage: false },
232 ),
233 );
234
235 it.effect("surfaces typed safe storage availability failures", () => {
236 const cause = new Error("safe storage unavailable");
237 return withSavedEnvironments(
238 Effect.gen(function* () {
239 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
240 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
241
242 const error = yield* savedEnvironments
243 .setSecret({
244 environmentId: savedRegistryRecord.environmentId,
245 secret: "next-token",
246 })
247 .pipe(Effect.flip);
248
249 assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError);
250 assert.equal(error.cause, cause);
251 }),
252 { availabilityError: cause },
253 );
254 });
255
256 it.effect("removes saved environment secrets", () =>
257 withSavedEnvironments(
258 Effect.gen(function* () {
259 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
260 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
261 yield* savedEnvironments.setSecret({
262 environmentId: savedRegistryRecord.environmentId,
263 secret: "bearer-token",
264 });
265
266 yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId);
267
268 assert.isTrue(
269 Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
270 );
271 }),
272 ),
273 );
274
275 it.effect("treats empty saved environment documents as empty", () =>
276 withSavedEnvironments(
277 Effect.gen(function* () {
278 const environment = yield* DesktopEnvironment.DesktopEnvironment;
279 const fileSystem = yield* FileSystem.FileSystem;
280 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
281 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
282 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n");
283
284 assert.deepEqual(yield* savedEnvironments.getRegistry, []);
285 assert.isTrue(
286 Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
287 );
288 }),
289 ),
290 );
291
292 it.effect("treats malformed saved environment documents as empty", () =>
293 withSavedEnvironments(
294 Effect.gen(function* () {
295 const environment = yield* DesktopEnvironment.DesktopEnvironment;
296 const fileSystem = yield* FileSystem.FileSystem;
297 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
298 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
299 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json");
300
301 assert.deepEqual(yield* savedEnvironments.getRegistry, []);
302 assert.isTrue(
303 Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
304 );
305 }),
306 ),
307 );
308
309 it.effect("returns false when writing a secret without metadata", () =>
310 withSavedEnvironments(
311 Effect.gen(function* () {
312 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
313
314 assert.isFalse(
315 yield* savedEnvironments.setSecret({
316 environmentId: savedRegistryRecord.environmentId,
317 secret: "bearer-token",
318 }),
319 );
320 }),
321 ),
322 );
323
324 it.effect("preserves encrypted secrets when metadata is rewritten", () =>
325 withSavedEnvironments(
326 Effect.gen(function* () {
327 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
328 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
329 yield* savedEnvironments.setSecret({
330 environmentId: savedRegistryRecord.environmentId,
331 secret: "bearer-token",
332 });
333
334 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
335
336 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
337 assert.deepEqual(
338 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
339 Option.some("bearer-token"),
340 );
341 }),
342 ),
343 );
344});
apps/desktop/src/settings/DesktopSavedEnvironments.ts created+390
...@@ -0,0 +1,390 @@
1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Random from "effect/Random";
13import * as Schema from "effect/Schema";
14import * as Ref from "effect/Ref";
15
16import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
18
19type PersistedSavedEnvironmentDesktopSsh = NonNullable<
20 PersistedSavedEnvironmentRecord["desktopSsh"]
21>;
22
23interface PersistedSavedEnvironmentStorageRecord extends Omit<
24 PersistedSavedEnvironmentRecord,
25 "desktopSsh"
26> {
27 readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh;
28 readonly encryptedBearerToken?: string;
29}
30
31interface SavedEnvironmentRegistryDocument {
32 readonly version: number;
33 readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
34}
35
36interface SavedEnvironmentRegistryStorageDocument {
37 readonly version?: number;
38 readonly records?: readonly PersistedSavedEnvironmentStorageRecord[];
39}
40
41const DesktopSshTargetSchema = Schema.Struct({
42 alias: Schema.String,
43 hostname: Schema.String,
44 username: Schema.NullOr(Schema.String),
45 port: Schema.NullOr(Schema.Number),
46});
47
48const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({
49 environmentId: EnvironmentId,
50 label: Schema.String,
51 httpBaseUrl: Schema.String,
52 wsBaseUrl: Schema.String,
53 createdAt: Schema.String,
54 lastConnectedAt: Schema.NullOr(Schema.String),
55 desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),
56 encryptedBearerToken: Schema.optionalKey(Schema.String),
57});
58
59const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({
60 version: Schema.optionalKey(Schema.Number),
61 records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)),
62});
63
64const SavedEnvironmentRegistryDocumentJson = fromLenientJson(
65 SavedEnvironmentRegistryDocumentSchema,
66);
67const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(
68 SavedEnvironmentRegistryDocumentJson,
69);
70const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(
71 SavedEnvironmentRegistryDocumentJson,
72);
73
74export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError(
75 "DesktopSavedEnvironmentsWriteError",
76)<{
77 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
78}> {
79 override get message() {
80 return `Failed to write desktop saved environments: ${this.cause.message}`;
81 }
82}
83
84export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError(
85 "DesktopSavedEnvironmentSecretDecodeError",
86)<{
87 readonly cause: Encoding.EncodingError;
88}> {
89 override get message() {
90 return "Failed to decode desktop saved environment secret.";
91 }
92}
93
94export type DesktopSavedEnvironmentsGetSecretError =
95 | DesktopSavedEnvironmentSecretDecodeError
96 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
97 | ElectronSafeStorage.ElectronSafeStorageDecryptError;
98
99export type DesktopSavedEnvironmentsSetSecretError =
100 | DesktopSavedEnvironmentsWriteError
101 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
102 | ElectronSafeStorage.ElectronSafeStorageEncryptError;
103
104export interface DesktopSavedEnvironmentsShape {
105 readonly getRegistry: Effect.Effect<readonly PersistedSavedEnvironmentRecord[]>;
106 readonly setRegistry: (
107 records: readonly PersistedSavedEnvironmentRecord[],
108 ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
109 readonly getSecret: (
110 environmentId: string,
111 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
112 readonly setSecret: (input: {
113 readonly environmentId: string;
114 readonly secret: string;
115 }) => Effect.Effect<boolean, DesktopSavedEnvironmentsSetSecretError>;
116 readonly removeSecret: (
117 environmentId: string,
118 ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
119}
120
121export class DesktopSavedEnvironments extends Context.Service<
122 DesktopSavedEnvironments,
123 DesktopSavedEnvironmentsShape
124>()("t3/desktop/SavedEnvironments") {}
125
126function toPersistedSavedEnvironmentRecord(
127 record: PersistedSavedEnvironmentStorageRecord,
128): PersistedSavedEnvironmentRecord {
129 const nextRecord = {
130 environmentId: record.environmentId,
131 label: record.label,
132 httpBaseUrl: record.httpBaseUrl,
133 wsBaseUrl: record.wsBaseUrl,
134 createdAt: record.createdAt,
135 lastConnectedAt: record.lastConnectedAt,
136 };
137 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
138}
139
140function toSavedEnvironmentStorageRecord(
141 record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord,
142 encryptedBearerToken: Option.Option<string>,
143): PersistedSavedEnvironmentStorageRecord {
144 const nextRecord = {
145 environmentId: record.environmentId,
146 label: record.label,
147 httpBaseUrl: record.httpBaseUrl,
148 wsBaseUrl: record.wsBaseUrl,
149 createdAt: record.createdAt,
150 lastConnectedAt: record.lastConnectedAt,
151 };
152 const desktopSsh = record.desktopSsh;
153 if (desktopSsh) {
154 return Option.match(encryptedBearerToken, {
155 onNone: () => ({ ...nextRecord, desktopSsh }),
156 onSome: (value) => ({
157 ...nextRecord,
158 desktopSsh,
159 encryptedBearerToken: value,
160 }),
161 });
162 }
163 return Option.match(encryptedBearerToken, {
164 onNone: () => nextRecord,
165 onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }),
166 });
167}
168
169function normalizeSavedEnvironmentRegistryDocument(
170 document: SavedEnvironmentRegistryStorageDocument,
171): SavedEnvironmentRegistryDocument {
172 return {
173 version: document.version ?? 1,
174 records: document.records ?? [],
175 };
176}
177
178function readRegistryDocument(
179 fileSystem: FileSystem.FileSystem,
180 registryPath: string,
181): Effect.Effect<SavedEnvironmentRegistryDocument> {
182 return fileSystem.readFileString(registryPath).pipe(
183 Effect.option,
184 Effect.flatMap(
185 Option.match({
186 onNone: () => Effect.succeed({ version: 1, records: [] }),
187 onSome: (raw) =>
188 decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(
189 Effect.map(normalizeSavedEnvironmentRegistryDocument),
190 Effect.catch(() => Effect.succeed({ version: 1, records: [] })),
191 ),
192 }),
193 ),
194 );
195}
196
197const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")(
198 function* (input: {
199 readonly fileSystem: FileSystem.FileSystem;
200 readonly path: Path.Path;
201 readonly registryPath: string;
202 readonly document: SavedEnvironmentRegistryDocument;
203 }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
204 const directory = input.path.dirname(input.registryPath);
205 const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
206 const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`;
207 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document);
208 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
209 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
210 yield* input.fileSystem.rename(tempPath, input.registryPath);
211 },
212);
213
214function preserveExistingSecrets(
215 currentDocument: SavedEnvironmentRegistryDocument,
216 records: readonly PersistedSavedEnvironmentRecord[],
217): SavedEnvironmentRegistryDocument {
218 const encryptedBearerTokenById = new Map(
219 currentDocument.records.flatMap((record) =>
220 record.encryptedBearerToken
221 ? [[record.environmentId, record.encryptedBearerToken] as const]
222 : [],
223 ),
224 );
225
226 return {
227 version: currentDocument.version,
228 records: records.map((record) => {
229 const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
230 return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken));
231 }),
232 };
233}
234
235function decodeSecretBytes(
236 encoded: string,
237): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {
238 return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
239 Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })),
240 );
241}
242
243export const layer = Layer.effect(
244 DesktopSavedEnvironments,
245 Effect.gen(function* () {
246 const environment = yield* DesktopEnvironment.DesktopEnvironment;
247 const fileSystem = yield* FileSystem.FileSystem;
248 const path = yield* Path.Path;
249 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
250
251 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
252 writeRegistryDocument({
253 fileSystem,
254 path,
255 registryPath: environment.savedEnvironmentRegistryPath,
256 document,
257 }).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
258
259 return DesktopSavedEnvironments.of({
260 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
261 Effect.map((document) =>
262 document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
263 ),
264 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
265 ),
266 setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {
267 const currentDocument = yield* readRegistryDocument(
268 fileSystem,
269 environment.savedEnvironmentRegistryPath,
270 );
271 yield* writeDocument(preserveExistingSecrets(currentDocument, records));
272 }),
273 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
274 yield* Effect.annotateCurrentSpan({ environmentId });
275 const document = yield* readRegistryDocument(
276 fileSystem,
277 environment.savedEnvironmentRegistryPath,
278 );
279 const encoded = Option.fromNullishOr(
280 document.records.find((record) => record.environmentId === environmentId)
281 ?.encryptedBearerToken,
282 );
283 if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) {
284 return Option.none<string>();
285 }
286
287 const secretBytes = yield* decodeSecretBytes(encoded.value);
288 return Option.some(yield* safeStorage.decryptString(secretBytes));
289 }),
290 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
291 const { environmentId, secret } = input;
292 yield* Effect.annotateCurrentSpan({ environmentId });
293 const document = yield* readRegistryDocument(
294 fileSystem,
295 environment.savedEnvironmentRegistryPath,
296 );
297
298 if (!(yield* safeStorage.isEncryptionAvailable)) {
299 return false;
300 }
301
302 const encryptedBearerToken = Encoding.encodeBase64(
303 yield* safeStorage.encryptString(secret),
304 );
305 let found = false;
306 const nextDocument: SavedEnvironmentRegistryDocument = {
307 version: document.version,
308 records: document.records.map((record) => {
309 if (record.environmentId !== environmentId) {
310 return record;
311 }
312
313 found = true;
314 return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken));
315 }),
316 };
317
318 if (found) {
319 yield* writeDocument(nextDocument);
320 }
321 return found;
322 }),
323 removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) {
324 yield* Effect.annotateCurrentSpan({ environmentId });
325 const document = yield* readRegistryDocument(
326 fileSystem,
327 environment.savedEnvironmentRegistryPath,
328 );
329 if (
330 !document.records.some(
331 (record) =>
332 record.environmentId === environmentId && record.encryptedBearerToken !== undefined,
333 )
334 ) {
335 return;
336 }
337
338 yield* writeDocument({
339 version: document.version,
340 records: document.records.map((record) => {
341 if (record.environmentId !== environmentId) {
342 return record;
343 }
344 return toPersistedSavedEnvironmentRecord(record);
345 }),
346 });
347 }),
348 });
349 }),
350);
351
352export const layerTest = (input?: {
353 readonly records?: readonly PersistedSavedEnvironmentRecord[];
354 readonly secrets?: ReadonlyMap<string, string>;
355}) =>
356 Layer.effect(
357 DesktopSavedEnvironments,
358 Effect.gen(function* () {
359 const recordsRef = yield* Ref.make(input?.records ?? []);
360 const secretsRef = yield* Ref.make(new Map(input?.secrets ?? []));
361
362 return DesktopSavedEnvironments.of({
363 getRegistry: Ref.get(recordsRef),
364 setRegistry: (records) => Ref.set(recordsRef, records),
365 getSecret: (environmentId) =>
366 Ref.get(secretsRef).pipe(
367 Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))),
368 ),
369 setSecret: ({ environmentId, secret }) =>
370 Ref.get(recordsRef).pipe(
371 Effect.flatMap((records) => {
372 if (!records.some((record) => record.environmentId === environmentId)) {
373 return Effect.succeed(false);
374 }
375 return Ref.update(secretsRef, (secrets) => {
376 const nextSecrets = new Map(secrets);
377 nextSecrets.set(environmentId, secret);
378 return nextSecrets;
379 }).pipe(Effect.as(true));
380 }),
381 ),
382 removeSecret: (environmentId) =>
383 Ref.update(secretsRef, (secrets) => {
384 const nextSecrets = new Map(secrets);
385 nextSecrets.delete(environmentId);
386 return nextSecrets;
387 }),
388 });
389 }),
390 );
apps/desktop/src/shell/DesktopShellEnvironment.test.ts created+232
...@@ -0,0 +1,232 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
7
8import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
10
11const textEncoder = new TextEncoder();
12
13function envOutput(values: Readonly<Record<string, string>>): string {
14 return Object.entries(values)
15 .flatMap(([name, value]) => [
16 `__T3CODE_ENV_${name}_START__`,
17 value,
18 `__T3CODE_ENV_${name}_END__`,
19 ])
20 .join("\n");
21}
22
23function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle {
24 const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output));
25 return ChildProcessSpawner.makeHandle({
26 pid: ChildProcessSpawner.ProcessId(123),
27 stdout,
28 stderr: Stream.empty,
29 all: stdout,
30 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
31 isRunning: Effect.succeed(false),
32 kill: () => Effect.void,
33 stdin: Sink.drain,
34 getInputFd: () => Sink.drain,
35 getOutputFd: () => Stream.empty,
36 unref: Effect.succeed(Effect.void),
37 });
38}
39
40function withProcessEnv<A, E, R>(
41 env: NodeJS.ProcessEnv,
42 effect: Effect.Effect<A, E, R>,
43): Effect.Effect<A, E, R> {
44 return Effect.acquireUseRelease(
45 Effect.sync(() => {
46 const previous = process.env;
47 process.env = env;
48 return previous;
49 }),
50 () => effect,
51 (previous) =>
52 Effect.sync(() => {
53 process.env = previous;
54 }),
55 );
56}
57
58function runShellEnvironment(input: {
59 readonly env: NodeJS.ProcessEnv;
60 readonly platform: NodeJS.Platform;
61 readonly handler: (command: ChildProcess.Command) => string;
62}) {
63 const environmentLayer = Layer.succeed(
64 DesktopEnvironment.DesktopEnvironment,
65 DesktopEnvironment.DesktopEnvironment.of({
66 platform: input.platform,
67 } as DesktopEnvironment.DesktopEnvironmentShape),
68 );
69 const spawnerLayer = Layer.succeed(
70 ChildProcessSpawner.ChildProcessSpawner,
71 ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))),
72 );
73
74 const program = Effect.gen(function* () {
75 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
76 yield* shellEnvironment.installIntoProcess;
77 }).pipe(
78 Effect.provide(
79 DesktopShellEnvironment.layer.pipe(
80 Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)),
81 ),
82 ),
83 );
84
85 return withProcessEnv(input.env, program);
86}
87
88describe("DesktopShellEnvironment", () => {
89 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>
90 Effect.gen(function* () {
91 const env: NodeJS.ProcessEnv = {
92 SHELL: "/bin/zsh",
93 PATH: "/Users/test/.local/bin:/usr/bin",
94 };
95 const commands: ChildProcess.Command[] = [];
96
97 yield* runShellEnvironment({
98 env,
99 platform: "darwin",
100 handler: (command) => {
101 commands.push(command);
102 return envOutput({
103 PATH: "/opt/homebrew/bin:/usr/bin",
104 SSH_AUTH_SOCK: "/tmp/secretive.sock",
105 HOMEBREW_PREFIX: "/opt/homebrew",
106 });
107 },
108 });
109
110 assert.equal(commands.length, 1);
111 assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh");
112 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
113 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
114 assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew");
115 }),
116 );
117
118 it.effect("preserves inherited POSIX values when present", () =>
119 Effect.gen(function* () {
120 const env: NodeJS.ProcessEnv = {
121 SHELL: "/bin/zsh",
122 PATH: "/usr/bin",
123 SSH_AUTH_SOCK: "/tmp/inherited.sock",
124 };
125
126 yield* runShellEnvironment({
127 env,
128 platform: "darwin",
129 handler: () =>
130 envOutput({
131 PATH: "/opt/homebrew/bin:/usr/bin",
132 SSH_AUTH_SOCK: "/tmp/login-shell.sock",
133 }),
134 });
135
136 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
137 assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock");
138 }),
139 );
140
141 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
142 Effect.gen(function* () {
143 const env: NodeJS.ProcessEnv = {
144 SHELL: "/bin/zsh",
145 PATH: "/usr/bin",
146 };
147
148 yield* runShellEnvironment({
149 env,
150 platform: "linux",
151 handler: () =>
152 envOutput({
153 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
154 SSH_AUTH_SOCK: "/tmp/secretive.sock",
155 }),
156 });
157
158 assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin");
159 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
160 }),
161 );
162
163 it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () =>
164 Effect.gen(function* () {
165 const env: NodeJS.ProcessEnv = {
166 SHELL: "/opt/homebrew/bin/nu",
167 PATH: "/usr/bin",
168 };
169 const commands: string[] = [];
170
171 yield* runShellEnvironment({
172 env,
173 platform: "darwin",
174 handler: (command) => {
175 if (command._tag !== "StandardCommand") return "";
176 commands.push(command.command);
177 return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : "";
178 },
179 });
180
181 assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]);
182 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
183 }),
184 );
185
186 it.effect("loads PowerShell profile environment on Windows", () =>
187 Effect.gen(function* () {
188 const env: NodeJS.ProcessEnv = {
189 PATH: "C:\\Windows\\System32",
190 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
191 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
192 USERPROFILE: "C:\\Users\\testuser",
193 };
194
195 yield* runShellEnvironment({
196 env,
197 platform: "win32",
198 handler: (command) => {
199 if (command._tag !== "StandardCommand") return "";
200 const loadProfile = !command.args.includes("-NoProfile");
201 return loadProfile
202 ? envOutput({
203 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
204 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
205 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
206 })
207 : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" });
208 },
209 });
210
211 assert.equal(
212 env.PATH,
213 [
214 "C:\\Profile\\Node",
215 "C:\\Windows\\System32",
216 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
217 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
218 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
219 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
220 "C:\\Users\\testuser\\.bun\\bin",
221 "C:\\Users\\testuser\\scoop\\shims",
222 "C:\\Custom\\Bin",
223 ].join(";"),
224 );
225 assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm");
226 assert.equal(
227 env.FNM_MULTISHELL_PATH,
228 "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
229 );
230 }),
231 );
232});
apps/desktop/src/shell/DesktopShellEnvironment.ts created+356
...@@ -0,0 +1,356 @@
1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
7
8import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9
10type EnvironmentPatch = Record<string, string>;
11
12interface ShellEnvironmentConfig {
13 readonly env: NodeJS.ProcessEnv;
14 readonly platform: NodeJS.Platform;
15 readonly userShell: Option.Option<string>;
16}
17
18interface WindowsProbeOptions {
19 readonly loadProfile: boolean;
20}
21
22export interface DesktopShellEnvironmentShape {
23 readonly installIntoProcess: Effect.Effect<void, never>;
24}
25
26export class DesktopShellEnvironment extends Context.Service<
27 DesktopShellEnvironment,
28 DesktopShellEnvironmentShape
29>()("t3/desktop/ShellEnvironment") {}
30
31const LOGIN_SHELL_ENV_NAMES = [
32 "PATH",
33 "SSH_AUTH_SOCK",
34 "HOMEBREW_PREFIX",
35 "HOMEBREW_CELLAR",
36 "HOMEBREW_REPOSITORY",
37 "XDG_CONFIG_HOME",
38 "XDG_DATA_HOME",
39] as const;
40const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
41const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
42const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
43const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
44const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
45
46const trimNonEmpty = (value: string | null | undefined): Option.Option<string> =>
47 Option.fromNullishOr(value).pipe(
48 Option.map((entry) => entry.trim()),
49 Option.filter((entry) => entry.length > 0),
50 );
51
52const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":");
53
54const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
55 trimNonEmpty(env.PATH ?? env.Path ?? env.path);
56
57const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
58 const normalized = entry.trim().replace(/^"+|"+$/g, "");
59 return platform === "win32" ? normalized.toLowerCase() : normalized;
60};
61
62const mergePaths = (
63 platform: NodeJS.Platform,
64 values: ReadonlyArray<Option.Option<string>>,
65): Option.Option<string> => {
66 const delimiter = pathDelimiter(platform);
67 const entries: string[] = [];
68 const seen = new Set<string>();
69
70 for (const value of values) {
71 if (Option.isNone(value)) continue;
72
73 for (const entry of value.value.split(delimiter)) {
74 const trimmed = entry.trim();
75 if (trimmed.length === 0) continue;
76
77 const key = pathComparisonKey(trimmed, platform);
78 if (key.length === 0 || seen.has(key)) continue;
79
80 seen.add(key);
81 entries.push(trimmed);
82 }
83 }
84
85 return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();
86};
87
88const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray<string> => {
89 const fallback =
90 config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : "";
91 const seen = new Set<string>();
92 const candidates: string[] = [];
93
94 for (const candidate of [
95 trimNonEmpty(config.env.SHELL),
96 config.userShell,
97 trimNonEmpty(fallback),
98 ]) {
99 if (Option.isNone(candidate) || seen.has(candidate.value)) continue;
100 seen.add(candidate.value);
101 candidates.push(candidate.value);
102 }
103
104 return candidates;
105};
106
107const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
108 ...trimNonEmpty(env.APPDATA).pipe(
109 Option.match({
110 onNone: () => [],
111 onSome: (value) => [`${value}\\npm`],
112 }),
113 ),
114 ...trimNonEmpty(env.LOCALAPPDATA).pipe(
115 Option.match({
116 onNone: () => [],
117 onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`],
118 }),
119 ),
120 ...trimNonEmpty(env.USERPROFILE).pipe(
121 Option.match({
122 onNone: () => [],
123 onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`],
124 }),
125 ),
126];
127
128const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
129const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;
130
131const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
132 names
133 .map((name) => {
134 return [
135 `printf '%s\\n' '${startMarker(name)}'`,
136 `printenv ${name} || true`,
137 `printf '%s\\n' '${endMarker(name)}'`,
138 ].join("; ");
139 })
140 .join("; ");
141
142const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>
143 [
144 "$ErrorActionPreference = 'Stop'",
145 ...names.flatMap((name) => {
146 return [
147 `Write-Output '${startMarker(name)}'`,
148 `$value = [Environment]::GetEnvironmentVariable('${name}')`,
149 "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
150 `Write-Output '${endMarker(name)}'`,
151 ];
152 }),
153 ].join("; ");
154
155const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => {
156 const environment: EnvironmentPatch = {};
157
158 for (const name of names) {
159 const start = output.indexOf(startMarker(name));
160 if (start === -1) continue;
161
162 const valueStart = start + startMarker(name).length;
163 const end = output.indexOf(endMarker(name), valueStart);
164 if (end === -1) continue;
165
166 const value = output
167 .slice(valueStart, end)
168 .replace(/^\r?\n/, "")
169 .replace(/\r?\n$/, "");
170 if (value.length > 0) {
171 environment[name] = value;
172 }
173 }
174
175 return environment;
176};
177
178const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {
179 readonly command: string;
180 readonly args: ReadonlyArray<string>;
181 readonly timeout: Duration.Duration;
182 readonly shell?: boolean;
183}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
184 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
185 return yield* spawner
186 .string(
187 ChildProcess.make(input.command, input.args, {
188 shell: input.shell ?? false,
189 stdin: "ignore",
190 stdout: "pipe",
191 stderr: "pipe",
192 killSignal: "SIGTERM",
193 forceKillAfter: PROCESS_TERMINATE_GRACE,
194 }),
195 )
196 .pipe(
197 Effect.timeoutOption(input.timeout),
198 Effect.map(Option.getOrElse(() => "")),
199 Effect.catch(() => Effect.succeed("")),
200 );
201});
202
203const readLoginShellEnvironment = (
204 shell: string,
205 names: ReadonlyArray<string>,
206): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> =>
207 names.length === 0
208 ? Effect.succeed({})
209 : runCommandOutput({
210 command: shell,
211 args: ["-ilc", capturePosixEnvironmentCommand(names)],
212 timeout: LOGIN_SHELL_TIMEOUT,
213 }).pipe(Effect.map((output) => extractEnvironment(output, names)));
214
215const readLaunchctlPath: Effect.Effect<
216 Option.Option<string>,
217 never,
218 ChildProcessSpawner.ChildProcessSpawner
219> = runCommandOutput({
220 command: "/bin/launchctl",
221 args: ["getenv", "PATH"],
222 timeout: LAUNCHCTL_TIMEOUT,
223}).pipe(Effect.map(trimNonEmpty));
224
225const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")(
226 function* (
227 names: ReadonlyArray<string>,
228 options: WindowsProbeOptions,
229 ): Effect.fn.Return<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> {
230 if (names.length === 0) return {};
231
232 const args = [
233 "-NoLogo",
234 ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),
235 "-NonInteractive",
236 "-Command",
237 captureWindowsEnvironmentCommand(names),
238 ];
239
240 for (const command of WINDOWS_SHELL_CANDIDATES) {
241 const output = yield* runCommandOutput({
242 command,
243 args,
244 shell: true,
245 timeout: LOGIN_SHELL_TIMEOUT,
246 });
247 const environment = extractEnvironment(output, names);
248 if (Object.keys(environment).length > 0) {
249 return environment;
250 }
251 }
252
253 return {};
254 },
255);
256
257const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")(
258 function* (
259 config: ShellEnvironmentConfig,
260 ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
261 const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false });
262 const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, {
263 loadProfile: true,
264 });
265 const mergedPath = mergePaths("win32", [
266 trimNonEmpty(profile.PATH),
267 trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),
268 trimNonEmpty(noProfile.PATH),
269 readEnvPath(config.env),
270 ]);
271
272 if (Option.isSome(mergedPath)) {
273 config.env.PATH = mergedPath.value;
274 }
275 if (!config.env.FNM_DIR && profile.FNM_DIR) {
276 config.env.FNM_DIR = profile.FNM_DIR;
277 }
278 if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) {
279 config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH;
280 }
281 },
282);
283
284const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(
285 function* (
286 config: ShellEnvironmentConfig,
287 ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
288 const shellEnvironment: EnvironmentPatch = {};
289
290 for (const shell of listLoginShellCandidates(config)) {
291 Object.assign(
292 shellEnvironment,
293 yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),
294 );
295 if (shellEnvironment.PATH) break;
296 }
297
298 const launchctlPath =
299 config.platform === "darwin" && !shellEnvironment.PATH
300 ? yield* readLaunchctlPath
301 : Option.none<string>();
302 const mergedPath = mergePaths(config.platform, [
303 trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
304 readEnvPath(config.env),
305 ]);
306
307 if (Option.isSome(mergedPath)) {
308 config.env.PATH = mergedPath.value;
309 }
310 if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
311 config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
312 }
313
314 for (const name of [
315 "HOMEBREW_PREFIX",
316 "HOMEBREW_CELLAR",
317 "HOMEBREW_REPOSITORY",
318 "XDG_CONFIG_HOME",
319 "XDG_DATA_HOME",
320 ] as const) {
321 if (!config.env[name] && shellEnvironment[name]) {
322 config.env[name] = shellEnvironment[name];
323 }
324 }
325 },
326);
327
328const installShellEnvironment = (
329 config: ShellEnvironmentConfig,
330): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {
331 if (config.platform === "win32") {
332 return installWindowsEnvironment(config);
333 }
334 if (config.platform === "darwin" || config.platform === "linux") {
335 return installPosixEnvironment(config);
336 }
337 return Effect.void;
338};
339
340export const layer = Layer.effect(
341 DesktopShellEnvironment,
342 Effect.gen(function* () {
343 const environment = yield* DesktopEnvironment.DesktopEnvironment;
344 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
345 return DesktopShellEnvironment.of({
346 installIntoProcess: installShellEnvironment({
347 env: process.env,
348 platform: environment.platform,
349 userShell: Option.none(),
350 }).pipe(
351 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
352 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
353 ),
354 });
355 }),
356);
apps/desktop/src/ssh/DesktopSshEnvironment.test.ts created+118
...@@ -0,0 +1,118 @@
1import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as NetService from "@t3tools/shared/Net";
5import { SshPasswordPromptError } from "@t3tools/ssh/errors";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10
11import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts";
12import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
13
14function makeTempHomeDir() {
15 return Effect.gen(function* () {
16 const fs = yield* FileSystem.FileSystem;
17 return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" });
18 });
19}
20
21describe("sshEnvironment", () => {
22 it("treats password prompt timeouts as cancellable authentication prompts", () => {
23 assert.equal(
24 DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(
25 new SshPasswordPromptError({
26 message: "SSH authentication timed out for devbox.",
27 cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({
28 requestId: "prompt-1",
29 destination: "devbox",
30 }),
31 }),
32 ),
33 true,
34 );
35 });
36
37 it.effect("wires desktop host discovery through the ssh package runtime", () =>
38 Effect.gen(function* () {
39 const fs = yield* FileSystem.FileSystem;
40 const path = yield* Path.Path;
41 const homeDir = yield* makeTempHomeDir();
42 const sshDir = path.join(homeDir, ".ssh");
43 yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true });
44 yield* fs.writeFileString(
45 path.join(sshDir, "config"),
46 ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"),
47 );
48 yield* fs.writeFileString(
49 path.join(sshDir, "config.d", "team.conf"),
50 [
51 "Host staging",
52 " HostName staging.example.com",
53 "Host *",
54 " ServerAliveInterval 30",
55 "",
56 ].join("\n"),
57 );
58 yield* fs.writeFileString(
59 path.join(sshDir, "known_hosts"),
60 [
61 "known.example.com ssh-ed25519 AAAA",
62 "|1|hashed|entry ssh-ed25519 AAAA",
63 "[bastion.example.com]:2222 ssh-ed25519 AAAA",
64 "",
65 ].join("\n"),
66 );
67
68 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
69 const hosts = yield* sshEnvironment.discoverHosts({ homeDir });
70 assert.deepEqual(hosts, [
71 {
72 alias: "bastion.example.com",
73 hostname: "bastion.example.com",
74 username: null,
75 port: null,
76 source: "known-hosts",
77 },
78 {
79 alias: "devbox",
80 hostname: "devbox",
81 username: null,
82 port: null,
83 source: "ssh-config",
84 },
85 {
86 alias: "known.example.com",
87 hostname: "known.example.com",
88 username: null,
89 port: null,
90 source: "known-hosts",
91 },
92 {
93 alias: "staging",
94 hostname: "staging",
95 username: null,
96 port: null,
97 source: "ssh-config",
98 },
99 ]);
100 }).pipe(
101 Effect.provide(
102 DesktopSshEnvironment.layer().pipe(
103 Layer.provideMerge(
104 Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {
105 request: () => Effect.die("unexpected password prompt request"),
106 resolve: () => Effect.die("unexpected password prompt resolution"),
107 cancelPending: () => Effect.void,
108 }),
109 ),
110 Layer.provideMerge(NodeServices.layer),
111 Layer.provideMerge(NodeHttpClient.layerUndici),
112 Layer.provideMerge(NetService.layer),
113 ),
114 ),
115 Effect.scoped,
116 ),
117 );
118});
apps/desktop/src/ssh/DesktopSshEnvironment.ts created+150
...@@ -0,0 +1,150 @@
1import type {
2 DesktopDiscoveredSshHost,
3 DesktopSshEnvironmentBootstrap,
4 DesktopSshEnvironmentTarget,
5} from "@t3tools/contracts";
6import { type NetError, NetService } from "@t3tools/shared/Net";
7import {
8 SshPasswordPrompt,
9 type SshPasswordPromptShape,
10 type SshPasswordRequest,
11} from "@t3tools/ssh/auth";
12import { discoverSshHosts } from "@t3tools/ssh/config";
13import {
14 SshCommandError,
15 SshHostDiscoveryError,
16 SshInvalidTargetError,
17 SshLaunchError,
18 SshPairingError,
19 SshPasswordPromptError,
20 SshReadinessError,
21} from "@t3tools/ssh/errors";
22import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
23import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Path from "effect/Path";
28import { HttpClient } from "effect/unstable/http";
29import { ChildProcessSpawner } from "effect/unstable/process";
30
31import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
32
33export type DesktopSshEnvironmentRuntimeServices =
34 | ChildProcessSpawner.ChildProcessSpawner
35 | FileSystem.FileSystem
36 | Path.Path
37 | HttpClient.HttpClient
38 | NetService;
39
40export type DesktopSshEnvironmentOperationError =
41 | SshCommandError
42 | SshInvalidTargetError
43 | SshLaunchError
44 | SshPairingError
45 | SshReadinessError
46 | SshPasswordPromptError
47 | NetError;
48
49export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;
50
51export type DesktopSshEnvironmentError =
52 | DesktopSshEnvironmentDiscoverError
53 | DesktopSshEnvironmentOperationError;
54
55export interface DesktopSshEnvironmentShape {
56 readonly discoverHosts: (input?: {
57 readonly homeDir?: string;
58 }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>;
59 readonly ensureEnvironment: (
60 target: DesktopSshEnvironmentTarget,
61 options?: { readonly issuePairingToken?: boolean },
62 ) => Effect.Effect<DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentOperationError>;
63 readonly disconnectEnvironment: (
64 target: DesktopSshEnvironmentTarget,
65 ) => Effect.Effect<void, DesktopSshEnvironmentOperationError>;
66}
67
68export class DesktopSshEnvironment extends Context.Service<
69 DesktopSshEnvironment,
70 DesktopSshEnvironmentShape
71>()("t3/desktop/SshEnvironment") {}
72
73export interface DesktopSshEnvironmentLayerOptions {
74 readonly resolveCliPackageSpec?: () => string;
75 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;
76}
77
78function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {
79 return discoverSshHosts(input ?? {});
80}
81
82export function isDesktopSshPasswordPromptCancellation(
83 error: unknown,
84): error is SshPasswordPromptError {
85 return (
86 error instanceof SshPasswordPromptError &&
87 DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause)
88 );
89}
90
91const makePasswordPrompt = (
92 prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape,
93): SshPasswordPromptShape => ({
94 isAvailable: true,
95 request: (request: SshPasswordRequest) =>
96 prompts.request(request).pipe(
97 Effect.mapError(
98 (cause) =>
99 new SshPasswordPromptError({
100 message: cause.message,
101 cause,
102 }),
103 ),
104 ),
105});
106
107const make = Effect.gen(function* () {
108 const manager = yield* SshEnvironmentManager;
109 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
110 const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>();
111 const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts));
112
113 return DesktopSshEnvironment.of({
114 discoverHosts: (input) =>
115 discoverDesktopSshHostsEffect(input).pipe(
116 Effect.provide(runtimeContext),
117 Effect.withSpan("desktop.ssh.discoverHosts"),
118 ),
119 ensureEnvironment: (target, ensureOptions) =>
120 manager
121 .ensureEnvironment(target, ensureOptions)
122 .pipe(
123 Effect.provideService(SshPasswordPrompt, passwordPrompt),
124 Effect.provide(runtimeContext),
125 Effect.withSpan("desktop.ssh.ensureEnvironment"),
126 ),
127 disconnectEnvironment: (target) =>
128 manager
129 .disconnectEnvironment(target)
130 .pipe(
131 Effect.provideService(SshPasswordPrompt, passwordPrompt),
132 Effect.provide(runtimeContext),
133 Effect.withSpan("desktop.ssh.disconnectEnvironment"),
134 ),
135 });
136});
137
138export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>
139 Layer.effect(DesktopSshEnvironment, make).pipe(
140 Layer.provide(
141 SshEnvironmentManager.layer({
142 ...(options.resolveCliPackageSpec === undefined
143 ? {}
144 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
145 ...(options.resolveCliRunner === undefined
146 ? {}
147 : { resolveCliRunner: options.resolveCliRunner }),
148 }),
149 ),
150 );
apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts created+144
...@@ -0,0 +1,144 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import { TestClock } from "effect/testing";
8import type * as Electron from "electron";
9
10import * as ElectronWindow from "../electron/ElectronWindow.ts";
11import * as IpcChannels from "../ipc/channels.ts";
12import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
13
14interface SentMessage {
15 readonly channel: string;
16 readonly args: readonly unknown[];
17}
18
19function makeTestWindow() {
20 const listeners = new Map<string, Set<() => void>>();
21 const sentMessages: SentMessage[] = [];
22 let destroyed = false;
23 let minimized = true;
24 let restored = false;
25 let focused = false;
26
27 const window = {
28 isDestroyed: () => destroyed,
29 isMinimized: () => minimized,
30 restore: () => {
31 restored = true;
32 minimized = false;
33 },
34 focus: () => {
35 focused = true;
36 },
37 once: (eventName: string, listener: () => void) => {
38 const eventListeners = listeners.get(eventName) ?? new Set<() => void>();
39 eventListeners.add(listener);
40 listeners.set(eventName, eventListeners);
41 },
42 removeListener: (eventName: string, listener: () => void) => {
43 listeners.get(eventName)?.delete(listener);
44 },
45 webContents: {
46 send: (channel: string, ...args: readonly unknown[]) => {
47 sentMessages.push({ channel, args });
48 },
49 },
50 };
51
52 return {
53 window,
54 sentMessages,
55 isRestored: () => restored,
56 isFocused: () => focused,
57 close: () => {
58 destroyed = true;
59 const closedListeners = [...(listeners.get("closed") ?? [])];
60 listeners.delete("closed");
61 for (const listener of closedListeners) {
62 listener();
63 }
64 },
65 };
66}
67
68function makeElectronWindowLayer(window: ReturnType<typeof makeTestWindow>["window"]) {
69 return Layer.succeed(
70 ElectronWindow.ElectronWindow,
71 ElectronWindow.ElectronWindow.of({
72 create: () => Effect.die("unexpected BrowserWindow creation"),
73 main: Effect.succeed(Option.some(window as Electron.BrowserWindow)),
74 currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)),
75 focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)),
76 setMain: () => Effect.void,
77 clearMain: () => Effect.void,
78 reveal: () => Effect.void,
79 sendAll: () => Effect.void,
80 destroyAll: Effect.void,
81 syncAllAppearance: () => Effect.void,
82 }),
83 );
84}
85
86function makeLayer(window: ReturnType<typeof makeTestWindow>["window"]) {
87 return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe(
88 Layer.provide(makeElectronWindowLayer(window)),
89 Layer.provideMerge(TestClock.layer()),
90 );
91}
92
93describe("DesktopSshPasswordPrompts", () => {
94 it.effect("sends renderer prompts and resolves them by request id", () => {
95 const testWindow = makeTestWindow();
96
97 return Effect.gen(function* () {
98 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
99 const fiber = yield* prompts
100 .request({
101 destination: "devbox",
102 username: "julius",
103 prompt: "Enter the SSH password.",
104 attempt: 1,
105 })
106 .pipe(Effect.forkScoped);
107
108 yield* Effect.yieldNow;
109 assert.equal(testWindow.sentMessages.length, 1);
110 const sent = testWindow.sentMessages[0];
111 assert.ok(sent);
112 assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL);
113 const request = sent.args[0] as { readonly requestId: string; readonly destination: string };
114 assert.equal(request.destination, "devbox");
115 assert.equal(testWindow.isRestored(), true);
116 assert.equal(testWindow.isFocused(), true);
117
118 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
119 assert.equal(yield* Fiber.join(fiber), "secret");
120 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
121 });
122
123 it.effect("times out pending renderer prompts with a typed error", () => {
124 const testWindow = makeTestWindow();
125
126 return Effect.gen(function* () {
127 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
128 const fiber = yield* prompts
129 .request({
130 destination: "devbox",
131 username: null,
132 prompt: "Enter the SSH password.",
133 attempt: 1,
134 })
135 .pipe(Effect.forkScoped);
136
137 yield* Effect.yieldNow;
138 yield* TestClock.adjust(Duration.millis(1_000));
139 const error = yield* Fiber.join(fiber).pipe(Effect.flip);
140 assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError);
141 assert.equal(error.destination, "devbox");
142 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
143 });
144});
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts created+332
...@@ -0,0 +1,332 @@
1import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";
2import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts";
3import type { SshPasswordRequest } from "@t3tools/ssh/auth";
4import * as Context from "effect/Context";
5import * as Data from "effect/Data";
6import * as DateTime from "effect/DateTime";
7import * as Deferred from "effect/Deferred";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Random from "effect/Random";
13import * as Ref from "effect/Ref";
14
15import * as IpcChannels from "../ipc/channels.ts";
16import * as ElectronWindow from "../electron/ElectronWindow.ts";
17
18const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
19const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";
20
21type DesktopSshPasswordPromptResolutionInput =
22 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;
23
24export class DesktopSshPromptUnavailableError extends Data.TaggedError(
25 "DesktopSshPromptUnavailableError",
26)<{
27 readonly reason: string;
28}> {
29 override get message() {
30 return this.reason;
31 }
32}
33
34export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError(
35 "DesktopSshPromptWindowUnavailableError",
36)<{
37 readonly destination: string;
38}> {
39 override get message() {
40 return WINDOW_UNAVAILABLE_MESSAGE;
41 }
42}
43
44export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{
45 readonly requestId: string;
46 readonly destination: string;
47 readonly cause: unknown;
48}> {
49 override get message() {
50 return WINDOW_UNAVAILABLE_MESSAGE;
51 }
52}
53
54export class DesktopSshPromptTimedOutError extends Data.TaggedError(
55 "DesktopSshPromptTimedOutError",
56)<{
57 readonly requestId: string;
58 readonly destination: string;
59}> {
60 override get message() {
61 return `SSH authentication timed out for ${this.destination}.`;
62 }
63}
64
65export class DesktopSshPromptCancelledError extends Data.TaggedError(
66 "DesktopSshPromptCancelledError",
67)<{
68 readonly requestId: string;
69 readonly destination: string;
70 readonly reason: string;
71}> {
72 override get message() {
73 return this.reason;
74 }
75}
76
77export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError(
78 "DesktopSshPromptInvalidRequestIdError",
79)<{
80 readonly requestId: string;
81}> {
82 override get message() {
83 return "Invalid SSH password prompt id.";
84 }
85}
86
87export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{
88 readonly requestId: string;
89}> {
90 override get message() {
91 return "SSH password prompt expired. Try connecting again.";
92 }
93}
94
95export type DesktopSshPasswordPromptRequestError =
96 | DesktopSshPromptUnavailableError
97 | DesktopSshPromptWindowUnavailableError
98 | DesktopSshPromptSendError
99 | DesktopSshPromptTimedOutError
100 | DesktopSshPromptCancelledError;
101
102export type DesktopSshPasswordPromptResolveError =
103 | DesktopSshPromptInvalidRequestIdError
104 | DesktopSshPromptExpiredError;
105
106export type DesktopSshPasswordPromptError =
107 | DesktopSshPasswordPromptRequestError
108 | DesktopSshPasswordPromptResolveError;
109
110export function isDesktopSshPasswordPromptCancellation(
111 error: unknown,
112): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError {
113 return (
114 error instanceof DesktopSshPromptCancelledError ||
115 error instanceof DesktopSshPromptTimedOutError
116 );
117}
118
119export interface DesktopSshPasswordPromptsShape {
120 readonly request: (
121 request: SshPasswordRequest,
122 ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>;
123 readonly resolve: (
124 input: DesktopSshPasswordPromptResolutionInput,
125 ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>;
126 readonly cancelPending: (reason: string) => Effect.Effect<void>;
127}
128
129export class DesktopSshPasswordPrompts extends Context.Service<
130 DesktopSshPasswordPrompts,
131 DesktopSshPasswordPromptsShape
132>()("t3/desktop/SshPasswordPrompts") {}
133
134interface PendingSshPasswordPrompt {
135 readonly requestId: string;
136 readonly destination: string;
137 readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>;
138}
139
140interface LayerOptions {
141 readonly passwordPromptTimeoutMs?: number;
142}
143
144const removePending = (
145 pendingRef: Ref.Ref<Map<string, PendingSshPasswordPrompt>>,
146 requestId: string,
147) =>
148 Ref.modify(pendingRef, (pending) => {
149 const entry = pending.get(requestId);
150 if (entry === undefined) {
151 return [Option.none<PendingSshPasswordPrompt>(), pending] as const;
152 }
153
154 const nextPending = new Map(pending);
155 nextPending.delete(requestId);
156 return [Option.some(entry), nextPending] as const;
157 });
158
159const failPending = (
160 pending: PendingSshPasswordPrompt,
161 error: DesktopSshPasswordPromptRequestError,
162) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid);
163
164const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) {
165 const electronWindow = yield* ElectronWindow.ElectronWindow;
166 const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());
167 const passwordPromptTimeoutMs =
168 options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
169
170 const cancelPending = (reason: string): Effect.Effect<void> =>
171 Ref.getAndSet(pendingRef, new Map()).pipe(
172 Effect.flatMap((pending) =>
173 Effect.forEach(
174 pending.values(),
175 (entry) =>
176 failPending(
177 entry,
178 new DesktopSshPromptCancelledError({
179 requestId: entry.requestId,
180 destination: entry.destination,
181 reason,
182 }),
183 ),
184 { discard: true },
185 ),
186 ),
187 Effect.asVoid,
188 );
189
190 yield* Effect.addFinalizer(() =>
191 cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore),
192 );
193
194 const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* (
195 input: DesktopSshPasswordPromptResolutionInput,
196 ): Effect.fn.Return<void, DesktopSshPasswordPromptResolveError> {
197 const requestId = input.requestId.trim();
198 if (requestId.length === 0) {
199 return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId });
200 }
201
202 const pending = yield* removePending(pendingRef, requestId);
203 if (Option.isNone(pending)) {
204 return yield* new DesktopSshPromptExpiredError({ requestId });
205 }
206
207 const entry = pending.value;
208 if (input.password === null) {
209 yield* failPending(
210 entry,
211 new DesktopSshPromptCancelledError({
212 requestId,
213 destination: entry.destination,
214 reason: `SSH authentication cancelled for ${entry.destination}.`,
215 }),
216 );
217 return;
218 }
219
220 yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid);
221 });
222
223 const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* (
224 input: SshPasswordRequest,
225 ): Effect.fn.Return<string, DesktopSshPasswordPromptRequestError> {
226 const window = yield* electronWindow.main;
227 if (Option.isNone(window) || window.value.isDestroyed()) {
228 return yield* new DesktopSshPromptWindowUnavailableError({
229 destination: input.destination,
230 });
231 }
232
233 const requestId = yield* Random.nextUUIDv4;
234 const now = yield* DateTime.now;
235 const expiresAt = DateTime.formatIso(
236 DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }),
237 );
238 const promptRequest: DesktopSshPasswordPromptRequest = {
239 requestId,
240 destination: input.destination,
241 username: input.username,
242 prompt: input.prompt,
243 expiresAt,
244 };
245 const deferred = yield* Deferred.make<string, DesktopSshPasswordPromptRequestError>();
246 const pending: PendingSshPasswordPrompt = {
247 requestId,
248 destination: input.destination,
249 deferred,
250 };
251 yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending));
252
253 const context = yield* Effect.context();
254 const runFork = Effect.runForkWith(context);
255
256 const cancelOnWindowClosed = () => {
257 runFork(
258 removePending(pendingRef, requestId).pipe(
259 Effect.flatMap((entry) =>
260 Option.match(entry, {
261 onNone: () => Effect.void,
262 onSome: (pending) =>
263 failPending(
264 pending,
265 new DesktopSshPromptCancelledError({
266 requestId,
267 destination: input.destination,
268 reason: "SSH authentication was cancelled because the app window closed.",
269 }),
270 ),
271 }),
272 ),
273 ),
274 );
275 };
276 const cleanup = Effect.sync(() => {
277 if (!window.value.isDestroyed()) {
278 window.value.removeListener("closed", cancelOnWindowClosed);
279 }
280 }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);
281 const waitForPassword = Deferred.await(deferred).pipe(
282 Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
283 Effect.flatMap(
284 Option.match({
285 onNone: () =>
286 Effect.fail(
287 new DesktopSshPromptTimedOutError({
288 requestId,
289 destination: input.destination,
290 }),
291 ),
292 onSome: Effect.succeed,
293 }),
294 ),
295 );
296
297 return yield* Effect.try({
298 try: () => {
299 if (window.value.isDestroyed()) {
300 throw new Error(WINDOW_UNAVAILABLE_MESSAGE);
301 }
302 window.value.once("closed", cancelOnWindowClosed);
303 window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);
304 if (window.value.isDestroyed()) {
305 throw new Error(WINDOW_UNAVAILABLE_MESSAGE);
306 }
307 if (window.value.isMinimized()) {
308 window.value.restore();
309 }
310 if (window.value.isDestroyed()) {
311 throw new Error(WINDOW_UNAVAILABLE_MESSAGE);
312 }
313 window.value.focus();
314 },
315 catch: (cause) =>
316 new DesktopSshPromptSendError({
317 requestId,
318 destination: input.destination,
319 cause,
320 }),
321 }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
322 });
323
324 return DesktopSshPasswordPrompts.of({
325 request,
326 resolve,
327 cancelPending,
328 });
329});
330
331export const layer = (options: LayerOptions = {}) =>
332 Layer.effect(DesktopSshPasswordPrompts, make(options));
apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts created+79
...@@ -0,0 +1,79 @@
1import { assert, describe, it } from "@effect/vitest";
2import { SshHttpBridgeError } from "@t3tools/ssh/errors";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
6
7import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts";
8
9function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
10 return HttpClientResponse.fromWeb(
11 request,
12 new Response(JSON.stringify(body), {
13 status,
14 headers: { "content-type": "application/json" },
15 }),
16 );
17}
18
19function makeLayer(
20 handler: (
21 request: HttpClientRequest.HttpClientRequest,
22 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
23) {
24 return DesktopSshRemoteApi.layer.pipe(
25 Layer.provide(
26 Layer.succeed(
27 HttpClient.HttpClient,
28 HttpClient.make((request) => handler(request)),
29 ),
30 ),
31 );
32}
33
34describe("DesktopSshRemoteApi", () => {
35 it.effect("fetches and decodes the remote environment descriptor", () => {
36 const requestUrls: string[] = [];
37 const layer = makeLayer((request) =>
38 Effect.sync(() => {
39 requestUrls.push(request.url);
40 return jsonResponse(request, {
41 environmentId: "remote-env",
42 label: "Remote Devbox",
43 platform: { os: "linux", arch: "x64" },
44 serverVersion: "1.2.3",
45 capabilities: { repositoryIdentity: true },
46 });
47 }),
48 );
49
50 return Effect.gen(function* () {
51 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
52 const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({
53 httpBaseUrl: "http://127.0.0.1:41773/",
54 });
55
56 assert.equal(descriptor.label, "Remote Devbox");
57 assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
58 }).pipe(Effect.provide(layer));
59 });
60
61 it.effect("wraps schema decode failures in a typed remote api error", () => {
62 const layer = makeLayer((request) =>
63 Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
64 );
65
66 return Effect.gen(function* () {
67 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
68 const error = yield* remoteApi
69 .fetchEnvironmentDescriptor({
70 httpBaseUrl: "http://127.0.0.1:41773/",
71 })
72 .pipe(Effect.flip);
73
74 assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError);
75 assert.equal(error.operation, "fetch-environment-descriptor");
76 assert.equal(error.cause instanceof SshHttpBridgeError, false);
77 }).pipe(Effect.provide(layer));
78 });
79});
apps/desktop/src/ssh/DesktopSshRemoteApi.ts created+124
...@@ -0,0 +1,124 @@
1import {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 type AuthBearerBootstrapResult as AuthBearerBootstrapResultType,
6 type AuthSessionState as AuthSessionStateType,
7 type AuthWebSocketTokenResult as AuthWebSocketTokenResultType,
8 ExecutionEnvironmentDescriptor,
9 type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType,
10} from "@t3tools/contracts";
11import { SshHttpBridgeError } from "@t3tools/ssh/errors";
12import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel";
13import * as Context from "effect/Context";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18import { HttpClient } from "effect/unstable/http";
19
20export type DesktopSshRemoteApiOperation =
21 | "fetch-environment-descriptor"
22 | "bootstrap-bearer-session"
23 | "fetch-session-state"
24 | "issue-websocket-token";
25
26export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{
27 readonly operation: DesktopSshRemoteApiOperation;
28 readonly cause: SshHttpBridgeError | Schema.SchemaError;
29}> {
30 override get message() {
31 return `SSH remote API request failed during ${this.operation}.`;
32 }
33}
34
35export interface DesktopSshRemoteApiShape {
36 readonly fetchEnvironmentDescriptor: (input: {
37 readonly httpBaseUrl: string;
38 }) => Effect.Effect<ExecutionEnvironmentDescriptorType, DesktopSshRemoteApiError>;
39 readonly bootstrapBearerSession: (input: {
40 readonly httpBaseUrl: string;
41 readonly credential: string;
42 }) => Effect.Effect<AuthBearerBootstrapResultType, DesktopSshRemoteApiError>;
43 readonly fetchSessionState: (input: {
44 readonly httpBaseUrl: string;
45 readonly bearerToken: string;
46 }) => Effect.Effect<AuthSessionStateType, DesktopSshRemoteApiError>;
47 readonly issueWebSocketToken: (input: {
48 readonly httpBaseUrl: string;
49 readonly bearerToken: string;
50 }) => Effect.Effect<AuthWebSocketTokenResultType, DesktopSshRemoteApiError>;
51}
52
53export class DesktopSshRemoteApi extends Context.Service<
54 DesktopSshRemoteApi,
55 DesktopSshRemoteApiShape
56>()("t3/desktop/SshRemoteApi") {}
57
58const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect(
59 ExecutionEnvironmentDescriptor,
60);
61const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult);
62const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState);
63const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult);
64
65const mapError =
66 (operation: DesktopSshRemoteApiOperation) =>
67 (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError =>
68 new DesktopSshRemoteApiError({ operation, cause });
69
70const make = Effect.gen(function* () {
71 const httpClient = yield* HttpClient.HttpClient;
72 const provideHttpClient = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
73 effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
74
75 return DesktopSshRemoteApi.of({
76 fetchEnvironmentDescriptor: ({ httpBaseUrl }) =>
77 fetchLoopbackSshJson<unknown>({
78 httpBaseUrl,
79 pathname: "/.well-known/t3/environment",
80 }).pipe(
81 Effect.flatMap(decodeExecutionEnvironmentDescriptor),
82 Effect.mapError(mapError("fetch-environment-descriptor")),
83 provideHttpClient,
84 Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"),
85 ),
86 bootstrapBearerSession: ({ httpBaseUrl, credential }) =>
87 fetchLoopbackSshJson<unknown>({
88 httpBaseUrl,
89 pathname: "/api/auth/bootstrap/bearer",
90 method: "POST",
91 body: { credential },
92 }).pipe(
93 Effect.flatMap(decodeAuthBearerBootstrapResult),
94 Effect.mapError(mapError("bootstrap-bearer-session")),
95 provideHttpClient,
96 Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"),
97 ),
98 fetchSessionState: ({ httpBaseUrl, bearerToken }) =>
99 fetchLoopbackSshJson<unknown>({
100 httpBaseUrl,
101 pathname: "/api/auth/session",
102 bearerToken,
103 }).pipe(
104 Effect.flatMap(decodeAuthSessionState),
105 Effect.mapError(mapError("fetch-session-state")),
106 provideHttpClient,
107 Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"),
108 ),
109 issueWebSocketToken: ({ httpBaseUrl, bearerToken }) =>
110 fetchLoopbackSshJson<unknown>({
111 httpBaseUrl,
112 pathname: "/api/auth/ws-token",
113 method: "POST",
114 bearerToken,
115 }).pipe(
116 Effect.flatMap(decodeAuthWebSocketTokenResult),
117 Effect.mapError(mapError("issue-websocket-token")),
118 provideHttpClient,
119 Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"),
120 ),
121 });
122});
123
124export const layer = Layer.effect(DesktopSshRemoteApi, make);
apps/desktop/src/sshEnvironment.test.ts deleted-98
...@@ -1,98 +0,0 @@
1import * as FS from "node:fs";
2import * as OS from "node:os";
3import * as Path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { SshPasswordPromptError } from "@t3tools/ssh/errors";
8
9import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts";
10
11const tempDirectories: string[] = [];
12
13afterEach(() => {
14 for (const directory of tempDirectories.splice(0)) {
15 FS.rmSync(directory, { recursive: true, force: true });
16 }
17});
18
19function makeTempHomeDir(): string {
20 const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-"));
21 tempDirectories.push(directory);
22 return directory;
23}
24
25describe("sshEnvironment", () => {
26 it("treats password prompt timeouts as cancellable authentication prompts", () => {
27 expect(
28 isSshPasswordPromptCancellation(
29 new SshPasswordPromptError({
30 message: "SSH authentication timed out for devbox.",
31 }),
32 ),
33 ).toBe(true);
34 });
35
36 it("wires desktop host discovery through the ssh package runtime", async () => {
37 const homeDir = makeTempHomeDir();
38 const sshDir = Path.join(homeDir, ".ssh");
39 FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true });
40 FS.writeFileSync(
41 Path.join(sshDir, "config"),
42 ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"),
43 "utf8",
44 );
45 FS.writeFileSync(
46 Path.join(sshDir, "config.d", "team.conf"),
47 [
48 "Host staging",
49 " HostName staging.example.com",
50 "Host *",
51 " ServerAliveInterval 30",
52 "",
53 ].join("\n"),
54 "utf8",
55 );
56 FS.writeFileSync(
57 Path.join(sshDir, "known_hosts"),
58 [
59 "known.example.com ssh-ed25519 AAAA",
60 "|1|hashed|entry ssh-ed25519 AAAA",
61 "[bastion.example.com]:2222 ssh-ed25519 AAAA",
62 "",
63 ].join("\n"),
64 "utf8",
65 );
66
67 await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([
68 {
69 alias: "bastion.example.com",
70 hostname: "bastion.example.com",
71 username: null,
72 port: null,
73 source: "known-hosts",
74 },
75 {
76 alias: "devbox",
77 hostname: "devbox",
78 username: null,
79 port: null,
80 source: "ssh-config",
81 },
82 {
83 alias: "known.example.com",
84 hostname: "known.example.com",
85 username: null,
86 port: null,
87 source: "known-hosts",
88 },
89 {
90 alias: "staging",
91 hostname: "staging",
92 username: null,
93 port: null,
94 source: "ssh-config",
95 },
96 ]);
97 });
98});
apps/desktop/src/sshEnvironment.ts deleted-420
...@@ -1,420 +0,0 @@
1import * as Crypto from "node:crypto";
2
3import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { NetService } from "@t3tools/shared/Net";
6import type {
7 AuthBearerBootstrapResult,
8 AuthSessionState,
9 AuthWebSocketTokenResult,
10 DesktopDiscoveredSshHost,
11 DesktopSshEnvironmentTarget,
12 DesktopSshPasswordPromptRequest,
13 ExecutionEnvironmentDescriptor,
14} from "@t3tools/contracts";
15import {
16 SshPasswordPrompt,
17 type SshPasswordPromptShape,
18 type SshPasswordRequest,
19} from "@t3tools/ssh/auth";
20import { discoverSshHosts } from "@t3tools/ssh/config";
21import { SshPasswordPromptError } from "@t3tools/ssh/errors";
22import {
23 fetchLoopbackSshJson,
24 SshEnvironmentManager,
25 type RemoteT3RunnerOptions,
26} from "@t3tools/ssh/tunnel";
27import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
28
29export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
30
31const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
32const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
33const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
34const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
35const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
36const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
37const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
38const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
39const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
40const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
41const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
42
43interface DesktopSshEnvironmentManagerOptions {
44 readonly passwordProvider?: (request: SshPasswordRequest) => Promise<string | null>;
45 readonly resolveCliPackageSpec?: () => string;
46 readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
47}
48
49const sshRuntime = ManagedRuntime.make(
50 Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer),
51);
52
53function createDesktopSshRuntime(
54 passwordPrompt: SshPasswordPromptShape,
55 scope: Scope.Scope,
56 options: DesktopSshEnvironmentManagerOptions,
57) {
58 return ManagedRuntime.make(
59 Layer.mergeAll(
60 NodeServices.layer,
61 NodeHttpClient.layerUndici,
62 NetService.layer,
63 Layer.succeed(Scope.Scope, scope),
64 Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)),
65 SshEnvironmentManager.layer({
66 ...(options.resolveCliPackageSpec === undefined
67 ? {}
68 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
69 ...(options.resolveCliRunner === undefined
70 ? {}
71 : { resolveCliRunner: options.resolveCliRunner }),
72 }),
73 ),
74 );
75}
76
77export async function discoverDesktopSshHosts(input?: {
78 readonly homeDir?: string;
79}): Promise<readonly DesktopDiscoveredSshHost[]> {
80 return await sshRuntime.runPromise(discoverSshHosts(input ?? {}));
81}
82
83export class DesktopSshEnvironmentManager {
84 private readonly runtime: ReturnType<typeof createDesktopSshRuntime>;
85 private readonly scope: Scope.Scope;
86
87 constructor(options: DesktopSshEnvironmentManagerOptions = {}) {
88 const passwordPrompt: SshPasswordPromptShape = {
89 isAvailable: options.passwordProvider !== undefined,
90 request: (request) => {
91 const passwordProvider = options.passwordProvider;
92 if (!passwordProvider) {
93 return Effect.succeed(null);
94 }
95
96 return Effect.tryPromise({
97 try: () => passwordProvider(request),
98 catch: (cause) =>
99 new SshPasswordPromptError({
100 message: cause instanceof Error ? cause.message : "SSH password prompt failed.",
101 cause,
102 }),
103 });
104 },
105 };
106 this.scope = Effect.runSync(Scope.make());
107 this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options);
108 }
109
110 async discoverHosts(): Promise<readonly DesktopDiscoveredSshHost[]> {
111 return await discoverDesktopSshHosts();
112 }
113
114 async ensureEnvironment(
115 target: DesktopSshEnvironmentTarget,
116 options?: { readonly issuePairingToken?: boolean },
117 ) {
118 return await this.runtime.runPromise(
119 Effect.service(SshEnvironmentManager).pipe(
120 Effect.flatMap((manager) => manager.ensureEnvironment(target, options)),
121 ),
122 );
123 }
124
125 async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise<void> {
126 await this.runtime.runPromise(
127 Effect.service(SshEnvironmentManager).pipe(
128 Effect.flatMap((manager) => manager.disconnectEnvironment(target)),
129 ),
130 );
131 }
132
133 async dispose(): Promise<void> {
134 await this.runtime.runPromise(Scope.close(this.scope, Exit.void));
135 await this.runtime.dispose();
136 }
137}
138
139function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null {
140 if (typeof rawTarget !== "object" || rawTarget === null) {
141 return null;
142 }
143
144 const target = rawTarget as Partial<DesktopSshEnvironmentTarget>;
145 if (typeof target.alias !== "string" || typeof target.hostname !== "string") {
146 return null;
147 }
148 if (
149 target.username !== null &&
150 target.username !== undefined &&
151 typeof target.username !== "string"
152 ) {
153 return null;
154 }
155 if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) {
156 return null;
157 }
158
159 const alias = target.alias.trim();
160 const hostname = target.hostname.trim();
161 if (alias.length === 0 || hostname.length === 0) {
162 return null;
163 }
164
165 return {
166 alias,
167 hostname,
168 username: target.username?.trim() || null,
169 port: target.port ?? null,
170 };
171}
172
173/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */
174export interface DesktopSshBridgeWindow {
175 isDestroyed(): boolean;
176 isMinimized(): boolean;
177 restore(): void;
178 focus(): void;
179 readonly webContents: {
180 send(channel: string, ...args: readonly unknown[]): void;
181 };
182}
183
184/** Minimal subset of Electron's ipcMain used by the SSH bridge. */
185export interface DesktopSshBridgeIpcMain {
186 removeHandler(channel: string): void;
187 handle(
188 channel: string,
189 listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise<unknown>,
190 ): void;
191}
192
193export interface DesktopSshEnvironmentBridgeOptions {
194 readonly getMainWindow: () => DesktopSshBridgeWindow | null;
195 readonly resolveCliPackageSpec?: () => string;
196 readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
197 readonly passwordPromptTimeoutMs?: number;
198}
199
200interface PendingSshPasswordPrompt {
201 readonly resolve: (password: string | null) => void;
202 readonly reject: (error: Error) => void;
203 readonly timeout: ReturnType<typeof setTimeout>;
204}
205
206export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError {
207 const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : "";
208 return (
209 error instanceof SshPasswordPromptError &&
210 (message.includes("cancelled") || message.includes("timed out"))
211 );
212}
213
214/**
215 * Wires the SSH environment manager to Electron IPC, owning the renderer-facing
216 * password prompt state so `main.ts` only needs to register, cancel, and dispose.
217 */
218export class DesktopSshEnvironmentBridge {
219 private readonly options: DesktopSshEnvironmentBridgeOptions;
220 private readonly manager: DesktopSshEnvironmentManager;
221 private readonly pendingPrompts = new Map<string, PendingSshPasswordPrompt>();
222 private readonly passwordPromptTimeoutMs: number;
223
224 constructor(options: DesktopSshEnvironmentBridgeOptions) {
225 this.options = options;
226 this.passwordPromptTimeoutMs =
227 options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
228 this.manager = new DesktopSshEnvironmentManager({
229 passwordProvider: (request) => this.requestPasswordFromRenderer(request),
230 ...(options.resolveCliPackageSpec === undefined
231 ? {}
232 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
233 ...(options.resolveCliRunner === undefined
234 ? {}
235 : { resolveCliRunner: options.resolveCliRunner }),
236 });
237 }
238
239 registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void {
240 ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL);
241 ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts());
242
243 ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL);
244 ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => {
245 const target = getSafeDesktopSshTarget(rawTarget);
246 if (!target) {
247 throw new Error("Invalid desktop SSH target.");
248 }
249
250 const issuePairingToken =
251 typeof rawOptions === "object" &&
252 rawOptions !== null &&
253 "issuePairingToken" in rawOptions &&
254 (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true;
255
256 try {
257 return await this.manager.ensureEnvironment(target, {
258 issuePairingToken,
259 });
260 } catch (error) {
261 if (isSshPasswordPromptCancellation(error)) {
262 return {
263 type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT,
264 message: error.message,
265 };
266 }
267 throw error;
268 }
269 });
270
271 ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL);
272 ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => {
273 const target = getSafeDesktopSshTarget(rawTarget);
274 if (!target) {
275 throw new Error("Invalid desktop SSH target.");
276 }
277
278 await this.manager.disconnectEnvironment(target);
279 });
280
281 ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL);
282 ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) =>
283 sshRuntime.runPromise(
284 fetchLoopbackSshJson<ExecutionEnvironmentDescriptor>({
285 httpBaseUrl: rawHttpBaseUrl,
286 pathname: "/.well-known/t3/environment",
287 }),
288 ),
289 );
290
291 ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL);
292 ipcMain.handle(
293 BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
294 async (_event, rawHttpBaseUrl, rawCredential) =>
295 sshRuntime.runPromise(
296 fetchLoopbackSshJson<AuthBearerBootstrapResult>({
297 httpBaseUrl: rawHttpBaseUrl,
298 pathname: "/api/auth/bootstrap/bearer",
299 method: "POST",
300 body: { credential: rawCredential },
301 }),
302 ),
303 );
304
305 ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL);
306 ipcMain.handle(
307 FETCH_SSH_SESSION_STATE_CHANNEL,
308 async (_event, rawHttpBaseUrl, rawBearerToken) =>
309 sshRuntime.runPromise(
310 fetchLoopbackSshJson<AuthSessionState>({
311 httpBaseUrl: rawHttpBaseUrl,
312 pathname: "/api/auth/session",
313 bearerToken: rawBearerToken,
314 }),
315 ),
316 );
317
318 ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL);
319 ipcMain.handle(
320 ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
321 async (_event, rawHttpBaseUrl, rawBearerToken) =>
322 sshRuntime.runPromise(
323 fetchLoopbackSshJson<AuthWebSocketTokenResult>({
324 httpBaseUrl: rawHttpBaseUrl,
325 pathname: "/api/auth/ws-token",
326 method: "POST",
327 bearerToken: rawBearerToken,
328 }),
329 ),
330 );
331
332 ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL);
333 ipcMain.handle(
334 RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL,
335 async (_event, rawRequestId, rawPassword) => {
336 if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) {
337 throw new Error("Invalid SSH password prompt id.");
338 }
339 if (rawPassword !== null && typeof rawPassword !== "string") {
340 throw new Error("Invalid SSH password prompt response.");
341 }
342
343 const pending = this.pendingPrompts.get(rawRequestId);
344 if (!pending) {
345 throw new Error("SSH password prompt expired. Try connecting again.");
346 }
347
348 clearTimeout(pending.timeout);
349 this.pendingPrompts.delete(rawRequestId);
350 pending.resolve(rawPassword);
351 },
352 );
353 }
354
355 cancelPendingPasswordPrompts(reason: string): void {
356 for (const [requestId, pending] of this.pendingPrompts) {
357 clearTimeout(pending.timeout);
358 this.pendingPrompts.delete(requestId);
359 pending.reject(new Error(reason));
360 }
361 }
362
363 async dispose(): Promise<void> {
364 this.cancelPendingPasswordPrompts("SSH environment bridge disposed.");
365 await this.manager.dispose();
366 }
367
368 private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise<string | null> {
369 const window = this.options.getMainWindow();
370 if (!window || window.isDestroyed()) {
371 throw new Error("T3 Code window is not available for SSH authentication.");
372 }
373
374 const request: DesktopSshPasswordPromptRequest = {
375 requestId: Crypto.randomUUID(),
376 destination: input.destination,
377 username: input.username,
378 prompt: input.prompt,
379 expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(),
380 };
381
382 return await new Promise<string | null>((resolve, reject) => {
383 const rejectPrompt = (error: Error) => {
384 clearTimeout(timeout);
385 this.pendingPrompts.delete(request.requestId);
386 reject(error);
387 };
388 const timeout = setTimeout(() => {
389 this.pendingPrompts.delete(request.requestId);
390 reject(new Error(`SSH authentication timed out for ${input.destination}.`));
391 }, this.passwordPromptTimeoutMs);
392 timeout.unref();
393
394 this.pendingPrompts.set(request.requestId, { resolve, reject, timeout });
395
396 try {
397 if (window.isDestroyed()) {
398 throw new Error("T3 Code window is not available for SSH authentication.");
399 }
400 window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request);
401 if (window.isDestroyed()) {
402 throw new Error("T3 Code window is not available for SSH authentication.");
403 }
404 if (window.isMinimized()) {
405 window.restore();
406 }
407 if (window.isDestroyed()) {
408 throw new Error("T3 Code window is not available for SSH authentication.");
409 }
410 window.focus();
411 } catch (error) {
412 rejectPrompt(
413 error instanceof Error
414 ? error
415 : new Error("T3 Code window is not available for SSH authentication."),
416 );
417 }
418 });
419 }
420}
apps/desktop/src/syncShellEnvironment.test.ts deleted-289
...@@ -1,289 +0,0 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { syncShellEnvironment } from "./syncShellEnvironment.ts";
4
5describe("syncShellEnvironment", () => {
6 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
7 const env: NodeJS.ProcessEnv = {
8 SHELL: "/bin/zsh",
9 PATH: "/Users/test/.local/bin:/usr/bin",
10 };
11 const readEnvironment = vi.fn(() => ({
12 PATH: "/opt/homebrew/bin:/usr/bin",
13 SSH_AUTH_SOCK: "/tmp/secretive.sock",
14 HOMEBREW_PREFIX: "/opt/homebrew",
15 }));
16
17 syncShellEnvironment(env, {
18 platform: "darwin",
19 readEnvironment,
20 });
21
22 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
23 "PATH",
24 "SSH_AUTH_SOCK",
25 "HOMEBREW_PREFIX",
26 "HOMEBREW_CELLAR",
27 "HOMEBREW_REPOSITORY",
28 "XDG_CONFIG_HOME",
29 "XDG_DATA_HOME",
30 ]);
31 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
32 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
33 expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
34 });
35
36 it("preserves an inherited SSH_AUTH_SOCK value", () => {
37 const env: NodeJS.ProcessEnv = {
38 SHELL: "/bin/zsh",
39 PATH: "/usr/bin",
40 SSH_AUTH_SOCK: "/tmp/inherited.sock",
41 };
42 const readEnvironment = vi.fn(() => ({
43 PATH: "/opt/homebrew/bin:/usr/bin",
44 SSH_AUTH_SOCK: "/tmp/login-shell.sock",
45 }));
46
47 syncShellEnvironment(env, {
48 platform: "darwin",
49 readEnvironment,
50 });
51
52 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
53 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
54 });
55
56 it("preserves inherited values when the login shell omits them", () => {
57 const env: NodeJS.ProcessEnv = {
58 SHELL: "/bin/zsh",
59 PATH: "/usr/bin",
60 SSH_AUTH_SOCK: "/tmp/inherited.sock",
61 };
62 const readEnvironment = vi.fn(() => ({
63 PATH: "/opt/homebrew/bin:/usr/bin",
64 }));
65
66 syncShellEnvironment(env, {
67 platform: "darwin",
68 readEnvironment,
69 });
70
71 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
72 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
73 });
74
75 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => {
76 const env: NodeJS.ProcessEnv = {
77 SHELL: "/bin/zsh",
78 PATH: "/usr/bin",
79 };
80 const readEnvironment = vi.fn(() => ({
81 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
82 SSH_AUTH_SOCK: "/tmp/secretive.sock",
83 }));
84
85 syncShellEnvironment(env, {
86 platform: "linux",
87 readEnvironment,
88 });
89
90 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
91 "PATH",
92 "SSH_AUTH_SOCK",
93 "HOMEBREW_PREFIX",
94 "HOMEBREW_CELLAR",
95 "HOMEBREW_REPOSITORY",
96 "XDG_CONFIG_HOME",
97 "XDG_DATA_HOME",
98 ]);
99 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
100 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
101 });
102
103 it("falls back to launchctl PATH on macOS when shell probing does not return one", () => {
104 const env: NodeJS.ProcessEnv = {
105 SHELL: "/opt/homebrew/bin/nu",
106 PATH: "/usr/bin",
107 };
108 const readEnvironment = vi
109 .fn()
110 .mockImplementationOnce(() => {
111 throw new Error("unknown flag");
112 })
113 .mockImplementationOnce(() => ({}));
114 const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
115 const logWarning = vi.fn();
116
117 syncShellEnvironment(env, {
118 platform: "darwin",
119 readEnvironment,
120 readLaunchctlPath,
121 userShell: "/bin/zsh",
122 logWarning,
123 });
124
125 expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [
126 "PATH",
127 "SSH_AUTH_SOCK",
128 "HOMEBREW_PREFIX",
129 "HOMEBREW_CELLAR",
130 "HOMEBREW_REPOSITORY",
131 "XDG_CONFIG_HOME",
132 "XDG_DATA_HOME",
133 ]);
134 expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [
135 "PATH",
136 "SSH_AUTH_SOCK",
137 "HOMEBREW_PREFIX",
138 "HOMEBREW_CELLAR",
139 "HOMEBREW_REPOSITORY",
140 "XDG_CONFIG_HOME",
141 "XDG_DATA_HOME",
142 ]);
143 expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
144 expect(logWarning).toHaveBeenCalledWith(
145 "Failed to read login shell environment from /opt/homebrew/bin/nu.",
146 expect.any(Error),
147 );
148 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149 });
150
151 it("does nothing on unsupported platforms", () => {
152 const env: NodeJS.ProcessEnv = {
153 SHELL: "C:/Program Files/Git/bin/bash.exe",
154 PATH: "C:\\Windows\\System32",
155 SSH_AUTH_SOCK: "/tmp/inherited.sock",
156 };
157 const readEnvironment = vi.fn(() => ({
158 PATH: "/usr/local/bin:/usr/bin",
159 SSH_AUTH_SOCK: "/tmp/secretive.sock",
160 }));
161
162 syncShellEnvironment(env, {
163 platform: "freebsd",
164 readEnvironment,
165 });
166
167 expect(readEnvironment).not.toHaveBeenCalled();
168 expect(env.PATH).toBe("C:\\Windows\\System32");
169 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
170 });
171
172 it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => {
173 const env: NodeJS.ProcessEnv = {
174 PATH: "C:\\Windows\\System32",
175 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
176 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
177 USERPROFILE: "C:\\Users\\testuser",
178 };
179 const readWindowsEnvironment = vi.fn(() => ({
180 PATH: "C:\\Custom\\Bin;C:\\Windows\\System32",
181 }));
182 const isWindowsCommandAvailable = vi.fn(() => true);
183
184 syncShellEnvironment(env, {
185 platform: "win32",
186 readWindowsEnvironment,
187 isWindowsCommandAvailable,
188 });
189
190 expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
191 expect(env.PATH).toBe(
192 [
193 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
194 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
195 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
196 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
197 "C:\\Users\\testuser\\.bun\\bin",
198 "C:\\Users\\testuser\\scoop\\shims",
199 "C:\\Custom\\Bin",
200 "C:\\Windows\\System32",
201 ].join(";"),
202 );
203 expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1);
204 });
205
206 it("loads the PowerShell profile on Windows when node is not available", () => {
207 const env: NodeJS.ProcessEnv = {
208 PATH: "C:\\Windows\\System32",
209 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
210 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
211 USERPROFILE: "C:\\Users\\testuser",
212 };
213 const readWindowsEnvironment = vi.fn(
214 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
215 options?.loadProfile
216 ? {
217 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
218 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
219 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
220 }
221 : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" },
222 );
223 const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
224
225 syncShellEnvironment(env, {
226 platform: "win32",
227 readWindowsEnvironment,
228 isWindowsCommandAvailable,
229 });
230
231 expect(env.PATH).toBe(
232 [
233 "C:\\Profile\\Node",
234 "C:\\Windows\\System32",
235 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
236 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
237 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
238 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
239 "C:\\Users\\testuser\\.bun\\bin",
240 "C:\\Users\\testuser\\scoop\\shims",
241 "C:\\Custom\\Bin",
242 ].join(";"),
243 );
244 expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm");
245 expect(env.FNM_MULTISHELL_PATH).toBe(
246 "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
247 );
248 expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
249 expect(readWindowsEnvironment).toHaveBeenNthCalledWith(
250 2,
251 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
252 { loadProfile: true },
253 );
254 });
255
256 it("preserves baseline Windows env when the profile probe fails", () => {
257 const env: NodeJS.ProcessEnv = {
258 PATH: "C:\\Windows\\System32",
259 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
260 USERPROFILE: "C:\\Users\\testuser",
261 };
262 const readWindowsEnvironment = vi.fn(
263 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => {
264 if (options?.loadProfile) {
265 throw new Error("profile load failed");
266 }
267 return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" };
268 },
269 );
270 const isWindowsCommandAvailable = vi.fn(() => false);
271
272 syncShellEnvironment(env, {
273 platform: "win32",
274 readWindowsEnvironment,
275 isWindowsCommandAvailable,
276 });
277
278 expect(env.PATH).toBe(
279 [
280 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
281 "C:\\Users\\testuser\\.bun\\bin",
282 "C:\\Users\\testuser\\scoop\\shims",
283 "C:\\Custom\\Bin",
284 "C:\\Windows\\System32",
285 ].join(";"),
286 );
287 expect(env.SSH_AUTH_SOCK).toBeUndefined();
288 });
289});
apps/desktop/src/syncShellEnvironment.ts deleted-109
...@@ -1,109 +0,0 @@
1import {
2 listLoginShellCandidates,
3 mergePathEntries,
4 readPathFromLaunchctl,
5 readEnvironmentFromLoginShell,
6 resolveWindowsEnvironment,
7} from "@t3tools/shared/shell";
8import type {
9 CommandAvailabilityOptions,
10 ShellEnvironmentReader,
11 WindowsShellEnvironmentReader,
12} from "@t3tools/shared/shell";
13
14type WindowsCommandAvailabilityChecker = (
15 command: string,
16 options?: CommandAvailabilityOptions,
17) => boolean;
18
19const LOGIN_SHELL_ENV_NAMES = [
20 "PATH",
21 "SSH_AUTH_SOCK",
22 "HOMEBREW_PREFIX",
23 "HOMEBREW_CELLAR",
24 "HOMEBREW_REPOSITORY",
25 "XDG_CONFIG_HOME",
26 "XDG_DATA_HOME",
27] as const;
28
29function logShellEnvironmentWarning(message: string, error?: unknown): void {
30 console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
31}
32
33export function syncShellEnvironment(
34 env: NodeJS.ProcessEnv = process.env,
35 options: {
36 platform?: NodeJS.Platform;
37 readEnvironment?: ShellEnvironmentReader;
38 readWindowsEnvironment?: WindowsShellEnvironmentReader;
39 isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
40 readLaunchctlPath?: typeof readPathFromLaunchctl;
41 userShell?: string;
42 logWarning?: (message: string, error?: unknown) => void;
43 } = {},
44): void {
45 const platform = options.platform ?? process.platform;
46
47 const logWarning = options.logWarning ?? logShellEnvironmentWarning;
48 const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
49 const shellEnvironment: Partial<Record<string, string>> = {};
50
51 try {
52 if (platform === "win32") {
53 const repairedEnvironment = resolveWindowsEnvironment(env, {
54 ...(options.readWindowsEnvironment
55 ? { readEnvironment: options.readWindowsEnvironment }
56 : {}),
57 ...(options.isWindowsCommandAvailable
58 ? { commandAvailable: options.isWindowsCommandAvailable }
59 : {}),
60 });
61 for (const [key, value] of Object.entries(repairedEnvironment)) {
62 if (value !== undefined) {
63 env[key] = value;
64 }
65 }
66 return;
67 }
68
69 if (platform !== "darwin" && platform !== "linux") return;
70
71 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
72 try {
73 Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
74 if (shellEnvironment.PATH) {
75 break;
76 }
77 } catch (error) {
78 logWarning(`Failed to read login shell environment from ${shell}.`, error);
79 }
80 }
81
82 const launchctlPath =
83 platform === "darwin" && !shellEnvironment.PATH
84 ? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
85 : undefined;
86 const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform);
87 if (mergedPath) {
88 env.PATH = mergedPath;
89 }
90
91 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
92 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
93 }
94
95 for (const name of [
96 "HOMEBREW_PREFIX",
97 "HOMEBREW_CELLAR",
98 "HOMEBREW_REPOSITORY",
99 "XDG_CONFIG_HOME",
100 "XDG_DATA_HOME",
101 ] as const) {
102 if (!env[name] && shellEnvironment[name]) {
103 env[name] = shellEnvironment[name];
104 }
105 }
106 } catch (error) {
107 logWarning("Failed to synchronize the desktop shell environment.", error);
108 }
109}
apps/desktop/src/tailscaleEndpointProvider.test.ts deleted-122
...@@ -1,122 +0,0 @@
1import { describe, expect, it } from "vitest";
2import { Effect } from "effect";
3
4import {
5 isTailscaleIpv4Address,
6 parseTailscaleMagicDnsName,
7 resolveTailscaleAdvertisedEndpoints,
8} from "./tailscaleEndpointProvider.ts";
9
10describe("tailscale endpoint provider", () => {
11 it("detects Tailnet IPv4 addresses", () => {
12 expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true);
13 expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true);
14 expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false);
15 expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false);
16 });
17
18 it("parses MagicDNS names from tailscale status", async () => {
19 expect(
20 Effect.runSync(
21 parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })),
22 ),
23 ).toBe("desktop.tail.ts.net");
24 expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull();
25 await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined();
26 });
27
28 it("resolves Tailscale endpoints as add-on advertised endpoints", async () => {
29 await expect(
30 resolveTailscaleAdvertisedEndpoints({
31 port: 3773,
32 networkInterfaces: {
33 tailscale0: [
34 {
35 address: "100.100.100.100",
36 family: "IPv4",
37 internal: false,
38 netmask: "255.192.0.0",
39 cidr: "100.100.100.100/10",
40 mac: "00:00:00:00:00:00",
41 },
42 ],
43 },
44 statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }),
45 }),
46 ).resolves.toEqual([
47 {
48 id: "tailscale-ip:http://100.100.100.100:3773",
49 label: "Tailscale IP",
50 provider: {
51 id: "tailscale",
52 label: "Tailscale",
53 kind: "private-network",
54 isAddon: true,
55 },
56 httpBaseUrl: "http://100.100.100.100:3773/",
57 wsBaseUrl: "ws://100.100.100.100:3773/",
58 reachability: "private-network",
59 compatibility: {
60 hostedHttpsApp: "mixed-content-blocked",
61 desktopApp: "compatible",
62 },
63 source: "desktop-addon",
64 status: "available",
65 description: "Reachable from devices on the same Tailnet.",
66 },
67 {
68 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
69 label: "Tailscale HTTPS",
70 provider: {
71 id: "tailscale",
72 label: "Tailscale",
73 kind: "private-network",
74 isAddon: true,
75 },
76 httpBaseUrl: "https://desktop.tail.ts.net/",
77 wsBaseUrl: "wss://desktop.tail.ts.net/",
78 reachability: "private-network",
79 compatibility: {
80 hostedHttpsApp: "requires-configuration",
81 desktopApp: "compatible",
82 },
83 source: "desktop-addon",
84 status: "unavailable",
85 description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
86 },
87 ]);
88 });
89
90 it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => {
91 await expect(
92 resolveTailscaleAdvertisedEndpoints({
93 port: 3773,
94 networkInterfaces: {},
95 statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }),
96 serveEnabled: true,
97 probe: async () => true,
98 }),
99 ).resolves.toEqual([
100 {
101 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
102 label: "Tailscale HTTPS",
103 provider: {
104 id: "tailscale",
105 label: "Tailscale",
106 kind: "private-network",
107 isAddon: true,
108 },
109 httpBaseUrl: "https://desktop.tail.ts.net/",
110 wsBaseUrl: "wss://desktop.tail.ts.net/",
111 reachability: "private-network",
112 compatibility: {
113 hostedHttpsApp: "compatible",
114 desktopApp: "compatible",
115 },
116 source: "desktop-addon",
117 status: "available",
118 description: "HTTPS endpoint served by Tailscale Serve.",
119 },
120 ]);
121 });
122});
apps/desktop/src/tailscaleEndpointProvider.ts deleted-142
...@@ -1,142 +0,0 @@
1import type { NetworkInterfaceInfo } from "node:os";
2
3import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 createAdvertisedEndpoint,
7 type CreateAdvertisedEndpointInput,
8} from "@t3tools/client-runtime";
9import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
10import {
11 buildTailscaleHttpsBaseUrl,
12 isTailscaleIpv4Address,
13 parseTailscaleMagicDnsName,
14 probeTailscaleHttpsEndpoint,
15 readTailscaleStatus,
16} from "@t3tools/tailscale";
17import { Effect, Layer } from "effect";
18
19export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
20
21const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici);
22
23const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
24 id: "tailscale",
25 label: "Tailscale",
26 kind: "private-network",
27 isAddon: true,
28};
29
30function createTailscaleEndpoint(
31 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
32): AdvertisedEndpoint {
33 return createAdvertisedEndpoint({
34 ...input,
35 provider: TAILSCALE_ENDPOINT_PROVIDER,
36 source: "desktop-addon",
37 });
38}
39
40export function resolveTailscaleIpAdvertisedEndpoints(input: {
41 readonly port: number;
42 readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
43}): readonly AdvertisedEndpoint[] {
44 const seen = new Set<string>();
45 const endpoints: AdvertisedEndpoint[] = [];
46
47 for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
48 if (!interfaceAddresses) continue;
49
50 for (const address of interfaceAddresses) {
51 if (address.internal) continue;
52 if (address.family !== "IPv4") continue;
53 if (!isTailscaleIpv4Address(address.address)) continue;
54 if (seen.has(address.address)) continue;
55 seen.add(address.address);
56
57 endpoints.push(
58 createTailscaleEndpoint({
59 id: `tailscale-ip:http://${address.address}:${input.port}`,
60 label: "Tailscale IP",
61 httpBaseUrl: `http://${address.address}:${input.port}`,
62 reachability: "private-network",
63 status: "available",
64 description: "Reachable from devices on the same Tailnet.",
65 }),
66 );
67 }
68 }
69
70 return endpoints;
71}
72
73export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: {
74 readonly dnsName: string | null;
75 readonly serveEnabled: boolean;
76 readonly servePort?: number;
77 readonly probe?: (baseUrl: string) => Promise<boolean>;
78}): Promise<AdvertisedEndpoint | null> {
79 if (!input.dnsName) {
80 return null;
81 }
82
83 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
84 magicDnsName: input.dnsName,
85 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
86 });
87 const isReachable = input.serveEnabled
88 ? await (input.probe?.(httpBaseUrl) ??
89 Effect.runPromise(
90 probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
91 Effect.provide(TailscaleDesktopLayer),
92 ),
93 ))
94 : false;
95
96 return createTailscaleEndpoint({
97 id: `tailscale-magicdns:${httpBaseUrl}`,
98 label: "Tailscale HTTPS",
99 httpBaseUrl,
100 reachability: "private-network",
101 hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
102 status: isReachable ? "available" : "unavailable",
103 description: isReachable
104 ? "HTTPS endpoint served by Tailscale Serve."
105 : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
106 });
107}
108
109export async function resolveTailscaleAdvertisedEndpoints(input: {
110 readonly port: number;
111 readonly serveEnabled?: boolean;
112 readonly servePort?: number;
113 readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
114 readonly statusJson?: string | null;
115 readonly probe?: (baseUrl: string) => Promise<boolean>;
116}): Promise<readonly AdvertisedEndpoint[]> {
117 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
118 const dnsName =
119 input.statusJson === undefined
120 ? await Effect.runPromise(
121 readTailscaleStatus.pipe(
122 Effect.map((status) => status.magicDnsName),
123 Effect.catch(() => Effect.succeed(null)),
124 Effect.provide(TailscaleDesktopLayer),
125 ),
126 )
127 : input.statusJson
128 ? await Effect.runPromise(
129 parseTailscaleMagicDnsName(input.statusJson).pipe(
130 Effect.catch(() => Effect.succeed(null)),
131 ),
132 )
133 : null;
134 const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({
135 dnsName,
136 serveEnabled: input.serveEnabled === true,
137 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
138 ...(input.probe === undefined ? {} : { probe: input.probe }),
139 });
140
141 return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints;
142}
apps/desktop/src/updateChannels.test.ts deleted-41
...@@ -1,41 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 doesVersionMatchDesktopUpdateChannel,
5 isNightlyDesktopVersion,
6 resolveDefaultDesktopUpdateChannel,
7} from "./updateChannels.ts";
8
9describe("isNightlyDesktopVersion", () => {
10 it("detects packaged nightly versions", () => {
11 expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true);
12 });
13
14 it("does not flag stable versions as nightly", () => {
15 expect(isNightlyDesktopVersion("0.0.17")).toBe(false);
16 });
17});
18
19describe("resolveDefaultDesktopUpdateChannel", () => {
20 it("defaults stable builds to latest", () => {
21 expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest");
22 });
23
24 it("defaults nightly builds to nightly", () => {
25 expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly");
26 });
27});
28
29describe("doesVersionMatchDesktopUpdateChannel", () => {
30 it("accepts nightly releases on the nightly channel", () => {
31 expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true);
32 });
33
34 it("rejects stable releases on the nightly channel", () => {
35 expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false);
36 });
37
38 it("rejects nightly releases on the stable channel", () => {
39 expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false);
40 });
41});
apps/desktop/src/updateChannels.ts deleted-18
...@@ -1,18 +0,0 @@
1import type { DesktopUpdateChannel } from "@t3tools/contracts";
2
3const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
4
5export function isNightlyDesktopVersion(version: string): boolean {
6 return NIGHTLY_VERSION_PATTERN.test(version);
7}
8
9export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
11}
12
13export function doesVersionMatchDesktopUpdateChannel(
14 version: string,
15 channel: DesktopUpdateChannel,
16): boolean {
17 return resolveDefaultDesktopUpdateChannel(version) === channel;
18}
apps/desktop/src/updateMachine.test.ts deleted-140
...@@ -1,140 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 createInitialDesktopUpdateState,
5 reduceDesktopUpdateStateOnCheckFailure,
6 reduceDesktopUpdateStateOnCheckStart,
7 reduceDesktopUpdateStateOnDownloadComplete,
8 reduceDesktopUpdateStateOnDownloadFailure,
9 reduceDesktopUpdateStateOnDownloadProgress,
10 reduceDesktopUpdateStateOnDownloadStart,
11 reduceDesktopUpdateStateOnInstallFailure,
12 reduceDesktopUpdateStateOnNoUpdate,
13 reduceDesktopUpdateStateOnUpdateAvailable,
14} from "./updateMachine.ts";
15
16const runtimeInfo = {
17 hostArch: "x64",
18 appArch: "x64",
19 runningUnderArm64Translation: false,
20} as const;
21
22describe("updateMachine", () => {
23 it("clears transient errors when a check starts", () => {
24 const state = reduceDesktopUpdateStateOnCheckStart(
25 {
26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
27 enabled: true,
28 status: "error",
29 message: "network",
30 errorContext: "check",
31 canRetry: true,
32 },
33 "2026-03-04T00:00:00.000Z",
34 );
35
36 expect(state.status).toBe("checking");
37 expect(state.message).toBeNull();
38 expect(state.errorContext).toBeNull();
39 expect(state.canRetry).toBe(false);
40 });
41
42 it("records a check failure without exposing an action", () => {
43 const state = reduceDesktopUpdateStateOnCheckFailure(
44 {
45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
46 enabled: true,
47 status: "checking",
48 },
49 "network unavailable",
50 "2026-03-04T00:00:00.000Z",
51 );
52
53 expect(state.status).toBe("error");
54 expect(state.errorContext).toBe("check");
55 expect(state.canRetry).toBe(true);
56 });
57
58 it("preserves available version on download failure for retry", () => {
59 const state = reduceDesktopUpdateStateOnDownloadFailure(
60 {
61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
62 enabled: true,
63 status: "downloading",
64 availableVersion: "1.1.0",
65 downloadPercent: 43,
66 },
67 "checksum mismatch",
68 );
69
70 expect(state.status).toBe("available");
71 expect(state.availableVersion).toBe("1.1.0");
72 expect(state.errorContext).toBe("download");
73 expect(state.canRetry).toBe(true);
74 });
75
76 it("transitions to downloaded and then preserves install retry state", () => {
77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
78 {
79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
80 enabled: true,
81 status: "downloading",
82 availableVersion: "1.1.0",
83 },
84 "1.1.0",
85 );
86 const failedInstall = reduceDesktopUpdateStateOnInstallFailure(
87 downloaded,
88 "backend shutdown timed out",
89 );
90
91 expect(downloaded.status).toBe("downloaded");
92 expect(downloaded.downloadedVersion).toBe("1.1.0");
93 expect(failedInstall.status).toBe("downloaded");
94 expect(failedInstall.errorContext).toBe("install");
95 expect(failedInstall.canRetry).toBe(true);
96 });
97
98 it("clears stale download state when no update is available", () => {
99 const state = reduceDesktopUpdateStateOnNoUpdate(
100 {
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
102 enabled: true,
103 status: "error",
104 availableVersion: "1.1.0",
105 downloadedVersion: "1.1.0",
106 message: "old failure",
107 errorContext: "download",
108 canRetry: true,
109 },
110 "2026-03-04T00:00:00.000Z",
111 );
112
113 expect(state.status).toBe("up-to-date");
114 expect(state.availableVersion).toBeNull();
115 expect(state.downloadedVersion).toBeNull();
116 expect(state.message).toBeNull();
117 expect(state.errorContext).toBeNull();
118 });
119
120 it("tracks available, download start, and progress cleanly", () => {
121 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122 {
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
124 enabled: true,
125 status: "checking",
126 },
127 "1.1.0",
128 "2026-03-04T00:00:00.000Z",
129 );
130 const downloading = reduceDesktopUpdateStateOnDownloadStart(available);
131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132
133 expect(available.status).toBe("available");
134 expect(available.channel).toBe("latest");
135 expect(downloading.status).toBe("downloading");
136 expect(downloading.downloadPercent).toBe(0);
137 expect(progress.downloadPercent).toBe(55.5);
138 expect(progress.errorContext).toBeNull();
139 });
140});
apps/desktop/src/updateMachine.ts deleted-166
...@@ -1,166 +0,0 @@
1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateChannel,
4 DesktopUpdateState,
5} from "@t3tools/contracts";
6
7import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts";
8
9export function createInitialDesktopUpdateState(
10 currentVersion: string,
11 runtimeInfo: DesktopRuntimeInfo,
12 channel: DesktopUpdateChannel,
13): DesktopUpdateState {
14 return {
15 enabled: false,
16 status: "disabled",
17 channel,
18 currentVersion,
19 hostArch: runtimeInfo.hostArch,
20 appArch: runtimeInfo.appArch,
21 runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
22 availableVersion: null,
23 downloadedVersion: null,
24 downloadPercent: null,
25 checkedAt: null,
26 message: null,
27 errorContext: null,
28 canRetry: false,
29 };
30}
31
32export function reduceDesktopUpdateStateOnCheckStart(
33 state: DesktopUpdateState,
34 checkedAt: string,
35): DesktopUpdateState {
36 return {
37 ...state,
38 status: "checking",
39 checkedAt,
40 message: null,
41 downloadPercent: null,
42 errorContext: null,
43 canRetry: false,
44 };
45}
46
47export function reduceDesktopUpdateStateOnCheckFailure(
48 state: DesktopUpdateState,
49 message: string,
50 checkedAt: string,
51): DesktopUpdateState {
52 return {
53 ...state,
54 status: "error",
55 message,
56 checkedAt,
57 downloadPercent: null,
58 errorContext: "check",
59 canRetry: true,
60 };
61}
62
63export function reduceDesktopUpdateStateOnUpdateAvailable(
64 state: DesktopUpdateState,
65 version: string,
66 checkedAt: string,
67): DesktopUpdateState {
68 return {
69 ...state,
70 status: "available",
71 availableVersion: version,
72 downloadedVersion: null,
73 downloadPercent: null,
74 checkedAt,
75 message: null,
76 errorContext: null,
77 canRetry: false,
78 };
79}
80
81export function reduceDesktopUpdateStateOnNoUpdate(
82 state: DesktopUpdateState,
83 checkedAt: string,
84): DesktopUpdateState {
85 return {
86 ...state,
87 status: "up-to-date",
88 availableVersion: null,
89 downloadedVersion: null,
90 downloadPercent: null,
91 checkedAt,
92 message: null,
93 errorContext: null,
94 canRetry: false,
95 };
96}
97
98export function reduceDesktopUpdateStateOnDownloadStart(
99 state: DesktopUpdateState,
100): DesktopUpdateState {
101 return {
102 ...state,
103 status: "downloading",
104 downloadPercent: 0,
105 message: null,
106 errorContext: null,
107 canRetry: false,
108 };
109}
110
111export function reduceDesktopUpdateStateOnDownloadFailure(
112 state: DesktopUpdateState,
113 message: string,
114): DesktopUpdateState {
115 return {
116 ...state,
117 status: nextStatusAfterDownloadFailure(state),
118 message,
119 downloadPercent: null,
120 errorContext: "download",
121 canRetry: getCanRetryAfterDownloadFailure(state),
122 };
123}
124
125export function reduceDesktopUpdateStateOnDownloadProgress(
126 state: DesktopUpdateState,
127 percent: number,
128): DesktopUpdateState {
129 return {
130 ...state,
131 status: "downloading",
132 downloadPercent: percent,
133 message: null,
134 errorContext: null,
135 canRetry: false,
136 };
137}
138
139export function reduceDesktopUpdateStateOnDownloadComplete(
140 state: DesktopUpdateState,
141 version: string,
142): DesktopUpdateState {
143 return {
144 ...state,
145 status: "downloaded",
146 availableVersion: version,
147 downloadedVersion: version,
148 downloadPercent: 100,
149 message: null,
150 errorContext: null,
151 canRetry: true,
152 };
153}
154
155export function reduceDesktopUpdateStateOnInstallFailure(
156 state: DesktopUpdateState,
157 message: string,
158): DesktopUpdateState {
159 return {
160 ...state,
161 status: "downloaded",
162 message,
163 errorContext: "install",
164 canRetry: true,
165 };
166}
apps/desktop/src/updateState.test.ts deleted-175
...@@ -1,175 +0,0 @@
1import { describe, expect, it } from "vitest";
2import type { DesktopUpdateState } from "@t3tools/contracts";
3
4import {
5 getCanRetryAfterDownloadFailure,
6 getAutoUpdateDisabledReason,
7 nextStatusAfterDownloadFailure,
8 shouldBroadcastDownloadProgress,
9} from "./updateState.ts";
10
11const baseState: DesktopUpdateState = {
12 enabled: true,
13 status: "idle",
14 channel: "latest",
15 currentVersion: "1.0.0",
16 hostArch: "x64",
17 appArch: "x64",
18 runningUnderArm64Translation: false,
19 availableVersion: null,
20 downloadedVersion: null,
21 downloadPercent: null,
22 checkedAt: null,
23 message: null,
24 errorContext: null,
25 canRetry: false,
26};
27
28describe("shouldBroadcastDownloadProgress", () => {
29 it("broadcasts the first downloading progress update", () => {
30 expect(
31 shouldBroadcastDownloadProgress(
32 { ...baseState, status: "downloading", downloadPercent: null },
33 1,
34 ),
35 ).toBe(true);
36 });
37
38 it("skips progress updates within the same 10% bucket", () => {
39 expect(
40 shouldBroadcastDownloadProgress(
41 { ...baseState, status: "downloading", downloadPercent: 11.2 },
42 18.7,
43 ),
44 ).toBe(false);
45 });
46
47 it("broadcasts progress updates when a new 10% bucket is reached", () => {
48 expect(
49 shouldBroadcastDownloadProgress(
50 { ...baseState, status: "downloading", downloadPercent: 19.9 },
51 20.1,
52 ),
53 ).toBe(true);
54 });
55
56 it("broadcasts progress updates when a retry resets the download percentage", () => {
57 expect(
58 shouldBroadcastDownloadProgress(
59 { ...baseState, status: "downloading", downloadPercent: 50.4 },
60 0.2,
61 ),
62 ).toBe(true);
63 });
64});
65
66describe("getAutoUpdateDisabledReason", () => {
67 it("reports development builds as disabled", () => {
68 expect(
69 getAutoUpdateDisabledReason({
70 isDevelopment: true,
71 isPackaged: false,
72 platform: "darwin",
73 appImage: undefined,
74 disabledByEnv: false,
75 hasUpdateFeedConfig: true,
76 }),
77 ).toContain("packaged production builds");
78 });
79
80 it("reports packaged local builds without an update feed as disabled", () => {
81 expect(
82 getAutoUpdateDisabledReason({
83 isDevelopment: false,
84 isPackaged: true,
85 platform: "darwin",
86 appImage: undefined,
87 disabledByEnv: false,
88 hasUpdateFeedConfig: false,
89 }),
90 ).toContain("no update feed");
91 });
92
93 it("allows packaged builds with an update feed", () => {
94 expect(
95 getAutoUpdateDisabledReason({
96 isDevelopment: false,
97 isPackaged: true,
98 platform: "darwin",
99 appImage: undefined,
100 disabledByEnv: false,
101 hasUpdateFeedConfig: true,
102 }),
103 ).toBeNull();
104 });
105
106 it("reports env-disabled auto updates", () => {
107 expect(
108 getAutoUpdateDisabledReason({
109 isDevelopment: false,
110 isPackaged: true,
111 platform: "darwin",
112 appImage: undefined,
113 disabledByEnv: true,
114 hasUpdateFeedConfig: true,
115 }),
116 ).toContain("T3CODE_DISABLE_AUTO_UPDATE");
117 });
118
119 it("reports linux non-AppImage builds as disabled", () => {
120 expect(
121 getAutoUpdateDisabledReason({
122 isDevelopment: false,
123 isPackaged: true,
124 platform: "linux",
125 appImage: undefined,
126 disabledByEnv: false,
127 hasUpdateFeedConfig: true,
128 }),
129 ).toContain("AppImage");
130 });
131});
132
133describe("nextStatusAfterDownloadFailure", () => {
134 it("returns available when an update version is still known", () => {
135 expect(
136 nextStatusAfterDownloadFailure({
137 ...baseState,
138 status: "downloading",
139 availableVersion: "1.1.0",
140 }),
141 ).toBe("available");
142 });
143
144 it("returns error when no update version can be retried", () => {
145 expect(
146 nextStatusAfterDownloadFailure({
147 ...baseState,
148 status: "downloading",
149 availableVersion: null,
150 }),
151 ).toBe("error");
152 });
153});
154
155describe("getCanRetryAfterDownloadFailure", () => {
156 it("returns true when an available version is still present", () => {
157 expect(
158 getCanRetryAfterDownloadFailure({
159 ...baseState,
160 status: "downloading",
161 availableVersion: "1.1.0",
162 }),
163 ).toBe(true);
164 });
165
166 it("returns false when no version is available to retry", () => {
167 expect(
168 getCanRetryAfterDownloadFailure({
169 ...baseState,
170 status: "downloading",
171 availableVersion: null,
172 }),
173 ).toBe(false);
174 });
175});
apps/desktop/src/updateState.ts deleted-52
...@@ -1,52 +0,0 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
2
3export function shouldBroadcastDownloadProgress(
4 currentState: DesktopUpdateState,
5 nextPercent: number,
6): boolean {
7 if (currentState.status !== "downloading") {
8 return true;
9 }
10
11 const currentPercent = currentState.downloadPercent;
12 if (currentPercent === null) {
13 return true;
14 }
15
16 const previousStep = Math.floor(currentPercent / 10);
17 const nextStep = Math.floor(nextPercent / 10);
18 return nextStep !== previousStep || nextPercent === 100;
19}
20
21export function nextStatusAfterDownloadFailure(
22 currentState: DesktopUpdateState,
23): DesktopUpdateState["status"] {
24 return currentState.availableVersion ? "available" : "error";
25}
26
27export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean {
28 return currentState.availableVersion !== null;
29}
30
31export function getAutoUpdateDisabledReason(args: {
32 isDevelopment: boolean;
33 isPackaged: boolean;
34 platform: NodeJS.Platform;
35 appImage?: string | undefined;
36 disabledByEnv: boolean;
37 hasUpdateFeedConfig: boolean;
38}): string | null {
39 if (!args.hasUpdateFeedConfig) {
40 return "Automatic updates are not available because no update feed is configured.";
41 }
42 if (args.isDevelopment || !args.isPackaged) {
43 return "Automatic updates are only available in packaged production builds.";
44 }
45 if (args.disabledByEnv) {
46 return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
47 }
48 if (args.platform === "linux" && !args.appImage) {
49 return "Automatic updates on Linux require running the AppImage build.";
50 }
51 return null;
52}
apps/desktop/src/updates/DesktopUpdates.test.ts created+295
...@@ -0,0 +1,295 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import * as Deferred from "effect/Deferred";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import { TestClock } from "effect/testing";
12
13import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
14import * as DesktopConfig from "../app/DesktopConfig.ts";
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";
18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19import * as DesktopState from "../app/DesktopState.ts";
20import * as DesktopUpdates from "./DesktopUpdates.ts";
21
22interface UpdatesHarnessOptions {
23 readonly checkForUpdates?: Effect.Effect<
24 void,
25 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
26 >;
27 readonly env?: Record<string, string | undefined>;
28}
29
30const flushCallbacks = Effect.yieldNow;
31
32function makeHarness(options: UpdatesHarnessOptions = {}) {
33 let checkCount = 0;
34 let allowDowngrade = false;
35 const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
36 const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
37 const sentStates: DesktopUpdateState[] = [];
38
39 const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
40 const eventListeners = listeners.get(eventName) ?? new Set();
41 eventListeners.add(listener);
42 listeners.set(eventName, eventListeners);
43 };
44
45 const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
46 const eventListeners = listeners.get(eventName);
47 if (!eventListeners) {
48 return;
49 }
50 eventListeners.delete(listener);
51 if (eventListeners.size === 0) {
52 listeners.delete(eventName);
53 }
54 };
55
56 const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, {
57 setFeedURL: (options) =>
58 Effect.sync(() => {
59 feedUrls.push(options);
60 }),
61 setAutoDownload: () => Effect.void,
62 setAutoInstallOnAppQuit: () => Effect.void,
63 setChannel: () => Effect.void,
64 setAllowPrerelease: () => Effect.void,
65 allowDowngrade: Effect.sync(() => allowDowngrade),
66 setAllowDowngrade: (value) =>
67 Effect.sync(() => {
68 allowDowngrade = value;
69 }),
70 setDisableDifferentialDownload: () => Effect.void,
71 checkForUpdates: Effect.sync(() => {
72 checkCount += 1;
73 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
74 downloadUpdate: Effect.void,
75 quitAndInstall: () => Effect.void,
76 on: (eventName, listener) =>
77 Effect.acquireRelease(
78 Effect.sync(() => {
79 addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
80 }),
81 () =>
82 Effect.sync(() => {
83 removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
84 }),
85 ).pipe(Effect.asVoid),
86 } satisfies ElectronUpdater.ElectronUpdaterShape);
87
88 const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
89 create: () => Effect.die("unexpected BrowserWindow creation"),
90 main: Effect.succeed(Option.none()),
91 currentMainOrFirst: Effect.succeed(Option.none()),
92 focusedMainOrFirst: Effect.succeed(Option.none()),
93 setMain: () => Effect.void,
94 clearMain: () => Effect.void,
95 reveal: () => Effect.void,
96 sendAll: (_channel, state) =>
97 Effect.sync(() => {
98 sentStates.push(state as DesktopUpdateState);
99 }),
100 destroyAll: Effect.void,
101 syncAllAppearance: () => Effect.void,
102 } satisfies ElectronWindow.ElectronWindowShape);
103
104 const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {
105 start: Effect.void,
106 stop: () => Effect.void,
107 currentConfig: Effect.succeed(Option.none()),
108 snapshot: Effect.succeed({
109 desiredRunning: false,
110 ready: false,
111 activePid: Option.none(),
112 restartAttempt: 0,
113 restartScheduled: false,
114 }),
115 });
116
117 const environmentLayer = DesktopEnvironment.layer({
118 dirname: "/repo/apps/desktop/src",
119 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
120 platform: "darwin",
121 processArch: "x64",
122 appVersion: "1.2.3",
123 appPath: "/repo",
124 isPackaged: true,
125 resourcesPath: "/missing/resources",
126 runningUnderArm64Translation: false,
127 }).pipe(
128 Layer.provide(
129 Layer.mergeAll(
130 NodeServices.layer,
131 DesktopConfig.layerTest({
132 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
133 T3CODE_DESKTOP_MOCK_UPDATES: "true",
134 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
135 ...options.env,
136 }),
137 ),
138 ),
139 );
140
141 const layer = DesktopUpdates.layer.pipe(
142 Layer.provideMerge(updaterLayer),
143 Layer.provideMerge(windowLayer),
144 Layer.provideMerge(backendLayer),
145 Layer.provideMerge(DesktopState.layer),
146 Layer.provideMerge(DesktopAppSettings.layer),
147 Layer.provideMerge(
148 DesktopConfig.layerTest({
149 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
150 T3CODE_DESKTOP_MOCK_UPDATES: "true",
151 T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
152 ...options.env,
153 }),
154 ),
155 Layer.provideMerge(environmentLayer),
156 Layer.provideMerge(NodeServices.layer),
157 );
158
159 return {
160 layer,
161 checkCount: () => checkCount,
162 feedUrls: () => feedUrls,
163 listenerCount: () =>
164 Array.from(listeners.values()).reduce(
165 (total, eventListeners) => total + eventListeners.size,
166 0,
167 ),
168 sentStates,
169 emit: (eventName: string, payload?: unknown) => {
170 for (const listener of listeners.get(eventName) ?? []) {
171 listener(payload);
172 }
173 },
174 };
175}
176
177describe("DesktopUpdates", () => {
178 it.effect("configures the updater and runs startup checks on the test clock", () => {
179 const harness = makeHarness();
180
181 return Effect.gen(function* () {
182 yield* Effect.scoped(
183 Effect.gen(function* () {
184 const updates = yield* DesktopUpdates.DesktopUpdates;
185 yield* updates.configure;
186
187 const state = yield* updates.getState;
188 assert.equal(state.enabled, true);
189 assert.equal(state.status, "idle");
190 assert.deepEqual(harness.feedUrls(), [
191 { provider: "generic", url: "http://localhost:4141" },
192 ]);
193 assert.equal(harness.listenerCount(), 6);
194 assert.equal(harness.checkCount(), 0);
195
196 yield* TestClock.adjust(Duration.millis(15_000));
197 assert.equal(harness.checkCount(), 1);
198 }),
199 );
200
201 assert.equal(harness.listenerCount(), 0);
202 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
203 });
204
205 it.effect("updates and broadcasts state from updater events", () => {
206 const harness = makeHarness();
207
208 return Effect.scoped(
209 Effect.gen(function* () {
210 const updates = yield* DesktopUpdates.DesktopUpdates;
211 yield* updates.configure;
212
213 harness.emit("update-available", { version: "1.2.4" });
214 yield* flushCallbacks;
215
216 const state = yield* updates.getState;
217 assert.equal(state.status, "available");
218 assert.equal(state.availableVersion, "1.2.4");
219 assert.isNotNull(state.checkedAt);
220 assert.equal(harness.sentStates.at(-1)?.status, "available");
221 }),
222 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
223 });
224
225 it.effect("persists channel changes through the settings service", () => {
226 const harness = makeHarness();
227
228 return Effect.scoped(
229 Effect.gen(function* () {
230 const settings = yield* DesktopAppSettings.DesktopAppSettings;
231 const updates = yield* DesktopUpdates.DesktopUpdates;
232 yield* updates.configure;
233
234 const state = yield* updates.setChannel("nightly");
235 const persistedSettings = yield* settings.get;
236
237 assert.equal(state.channel, "nightly");
238 assert.equal(persistedSettings.updateChannel, "nightly");
239 assert.equal(persistedSettings.updateChannelConfiguredByUser, true);
240 }),
241 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
242 });
243
244 it.effect("does not persist an unchanged update channel as a user preference", () => {
245 const harness = makeHarness();
246
247 return Effect.scoped(
248 Effect.gen(function* () {
249 const settings = yield* DesktopAppSettings.DesktopAppSettings;
250 const updates = yield* DesktopUpdates.DesktopUpdates;
251 yield* updates.configure;
252
253 const state = yield* updates.setChannel("latest");
254 const persistedSettings = yield* settings.get;
255
256 assert.equal(state.channel, "latest");
257 assert.equal(persistedSettings.updateChannel, "latest");
258 assert.equal(persistedSettings.updateChannelConfiguredByUser, false);
259 }),
260 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
261 });
262
263 it.effect("fails channel changes with a typed error while a check is in progress", () =>
264 Effect.gen(function* () {
265 const checkStarted = yield* Deferred.make<void>();
266 const releaseCheck = yield* Deferred.make<void>();
267 const harness = makeHarness({
268 checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
269 Effect.andThen(Deferred.await(releaseCheck)),
270 ),
271 });
272
273 yield* Effect.scoped(
274 Effect.gen(function* () {
275 const updates = yield* DesktopUpdates.DesktopUpdates;
276 yield* updates.configure;
277
278 const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
279 yield* Deferred.await(checkStarted);
280
281 const exit = yield* Effect.exit(updates.setChannel("nightly"));
282 assert.equal(exit._tag, "Failure");
283 if (exit._tag === "Failure") {
284 const error = Cause.squash(exit.cause);
285 assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);
286 assert.equal(error.action, "check");
287 }
288
289 yield* Deferred.succeed(releaseCheck, undefined);
290 yield* Fiber.join(checkFiber);
291 }),
292 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
293 }),
294 );
295});
apps/desktop/src/updates/DesktopUpdates.ts created+664
...@@ -0,0 +1,664 @@
1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateActionResult,
4 DesktopUpdateChannel,
5 DesktopUpdateCheckResult,
6 DesktopUpdateState,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Context from "effect/Context";
10import * as Data from "effect/Data";
11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Ref from "effect/Ref";
18import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";
20
21import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
22import * as DesktopConfig from "../app/DesktopConfig.ts";
23import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
24import * as DesktopObservability from "../app/DesktopObservability.ts";
25import * as DesktopState from "../app/DesktopState.ts";
26import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
27import * as ElectronWindow from "../electron/ElectronWindow.ts";
28import * as IpcChannels from "../ipc/channels.ts";
29import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
30import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
31import {
32 createInitialDesktopUpdateState,
33 reduceDesktopUpdateStateOnCheckFailure,
34 reduceDesktopUpdateStateOnCheckStart,
35 reduceDesktopUpdateStateOnDownloadComplete,
36 reduceDesktopUpdateStateOnDownloadFailure,
37 reduceDesktopUpdateStateOnDownloadProgress,
38 reduceDesktopUpdateStateOnDownloadStart,
39 reduceDesktopUpdateStateOnInstallFailure,
40 reduceDesktopUpdateStateOnNoUpdate,
41 reduceDesktopUpdateStateOnUpdateAvailable,
42} from "./updateMachine.ts";
43
44const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
45const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
46
47const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
48type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
49
50const UpdateInfo = Schema.Struct({
51 version: Schema.String,
52});
53
54const DownloadProgressInfo = Schema.Struct({
55 percent: Schema.Number,
56});
57
58const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
59
60export class DesktopUpdateActionInProgressError extends Data.TaggedError(
61 "DesktopUpdateActionInProgressError",
62)<{
63 readonly action: "check" | "download" | "install";
64}> {
65 override get message() {
66 return `Cannot change update tracks while an update ${this.action} action is in progress.`;
67 }
68}
69
70export class DesktopUpdatePersistenceError extends Data.TaggedError(
71 "DesktopUpdatePersistenceError",
72)<{
73 readonly cause: DesktopAppSettings.DesktopSettingsWriteError;
74}> {
75 override get message() {
76 return "Failed to persist desktop update settings.";
77 }
78}
79
80export type DesktopUpdateConfigureError = never;
81
82export type DesktopUpdateSetChannelError =
83 | DesktopUpdateActionInProgressError
84 | DesktopUpdatePersistenceError;
85
86export interface DesktopUpdatesShape {
87 readonly getState: Effect.Effect<DesktopUpdateState>;
88 readonly emitState: Effect.Effect<void>;
89 readonly disabledReason: Effect.Effect<Option.Option<string>>;
90 readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;
91 readonly setChannel: (
92 channel: DesktopUpdateChannel,
93 ) => Effect.Effect<DesktopUpdateState, DesktopUpdateSetChannelError>;
94 readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;
95 readonly download: Effect.Effect<DesktopUpdateActionResult>;
96 readonly install: Effect.Effect<DesktopUpdateActionResult>;
97}
98
99export class DesktopUpdates extends Context.Service<DesktopUpdates, DesktopUpdatesShape>()(
100 "t3/desktop/Updates",
101) {}
102
103const {
104 logInfo: logUpdaterInfo,
105 logWarning: logUpdaterWarning,
106 logError: logUpdaterError,
107} = DesktopObservability.makeComponentLogger("desktop-updater");
108
109function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {
110 const entries: Record<string, string> = {};
111 for (const line of raw.split("\n")) {
112 const match = line.match(/^(\w+):\s*(.+)$/);
113 if (match?.[1] && match[2]) {
114 entries[match[1]] = match[2].trim();
115 }
116 }
117
118 return Schema.decodeUnknownEffect(AppUpdateYmlConfig)(entries).pipe(
119 Effect.map((config) => (config.provider ? Option.some(config) : Option.none())),
120 Effect.catch(() => Effect.succeed(Option.none<AppUpdateYmlConfig>())),
121 );
122}
123
124function createBaseUpdateState(
125 channel: DesktopUpdateChannel,
126 enabled: boolean,
127 environment: DesktopEnvironment.DesktopEnvironmentShape,
128): DesktopUpdateState {
129 return {
130 ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),
131 enabled,
132 status: enabled ? "idle" : "disabled",
133 };
134}
135
136function getCanRetryFromState(state: DesktopUpdateState): boolean {
137 return state.availableVersion !== null || state.downloadedVersion !== null;
138}
139
140function shouldBroadcastDownloadProgress(
141 currentState: DesktopUpdateState,
142 nextPercent: number,
143): boolean {
144 if (currentState.status !== "downloading") {
145 return true;
146 }
147
148 const currentPercent = currentState.downloadPercent;
149 if (currentPercent === null) {
150 return true;
151 }
152
153 const previousStep = Math.floor(currentPercent / 10);
154 const nextStep = Math.floor(nextPercent / 10);
155 return nextStep !== previousStep || nextPercent === 100;
156}
157
158function getAutoUpdateDisabledReason(args: {
159 isDevelopment: boolean;
160 isPackaged: boolean;
161 platform: NodeJS.Platform;
162 appImage?: string | undefined;
163 disabledByEnv: boolean;
164 hasUpdateFeedConfig: boolean;
165}): string | null {
166 if (!args.hasUpdateFeedConfig) {
167 return "Automatic updates are not available because no update feed is configured.";
168 }
169 if (args.isDevelopment || !args.isPackaged) {
170 return "Automatic updates are only available in packaged production builds.";
171 }
172 if (args.disabledByEnv) {
173 return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
174 }
175 if (args.platform === "linux" && !args.appImage) {
176 return "Automatic updates on Linux require running the AppImage build.";
177 }
178 return null;
179}
180
181function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
182 return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
183}
184
185const make = Effect.gen(function* () {
186 const config = yield* DesktopConfig.DesktopConfig;
187 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
188 const desktopState = yield* DesktopState.DesktopState;
189 const electronUpdater = yield* ElectronUpdater.ElectronUpdater;
190 const electronWindow = yield* ElectronWindow.ElectronWindow;
191 const environment = yield* DesktopEnvironment.DesktopEnvironment;
192 const fileSystem = yield* FileSystem.FileSystem;
193 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
194
195 const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
196 const updateCheckInFlightRef = yield* Ref.make(false);
197 const updateDownloadInFlightRef = yield* Ref.make(false);
198 const updateInstallInFlightRef = yield* Ref.make(false);
199 const updaterConfiguredRef = yield* Ref.make(false);
200 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
201 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
202 createInitialDesktopUpdateState(
203 environment.appVersion,
204 environment.runtimeInfo,
205 environment.defaultDesktopSettings.updateChannel,
206 ),
207 );
208
209 const emitState = Ref.get(updateStateRef).pipe(
210 Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),
211 );
212
213 const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
214 Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState));
215
216 const updateState = (
217 f: (state: DesktopUpdateState) => DesktopUpdateState,
218 ): Effect.Effect<DesktopUpdateState> =>
219 Ref.get(updateStateRef).pipe(
220 Effect.flatMap((state) => {
221 const nextState = f(state);
222 return setState(nextState).pipe(Effect.as(nextState));
223 }),
224 );
225
226 const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe(
227 Effect.option,
228 Effect.flatMap(
229 Option.match({
230 onNone: () => Effect.succeed(Option.none<AppUpdateYmlConfig>()),
231 onSome: parseAppUpdateYml,
232 }),
233 ),
234 );
235
236 const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(
237 Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
238 );
239
240 const resolveDisabledReason = Effect.gen(function* () {
241 const hasFeedConfig = yield* hasUpdateFeedConfig;
242 return Option.fromNullishOr(
243 getAutoUpdateDisabledReason({
244 isDevelopment: environment.isDevelopment,
245 isPackaged: environment.isPackaged,
246 platform: environment.platform,
247 appImage: Option.getOrUndefined(config.appImagePath),
248 disabledByEnv: config.disableAutoUpdate,
249 hasUpdateFeedConfig: hasFeedConfig,
250 }),
251 );
252 });
253
254 const resolveUpdaterErrorContext = Effect.gen(function* () {
255 if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const;
256 if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const;
257 if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const;
258 return (yield* Ref.get(updateStateRef)).errorContext;
259 });
260
261 const activeUpdateAction = Effect.gen(function* () {
262 if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const);
263 if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const);
264 if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const);
265 return Option.none<"check" | "download" | "install">();
266 });
267
268 const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
269 channel: DesktopUpdateChannel,
270 ) {
271 yield* Effect.annotateCurrentSpan({ channel });
272 const allowsPrerelease = channel === "nightly";
273 yield* electronUpdater.setChannel(channel);
274 yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
275 yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
276 yield* logUpdaterInfo("using update channel", {
277 channel,
278 allowPrerelease: allowsPrerelease,
279 allowDowngrade: allowsPrerelease,
280 });
281 });
282
283 const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));
284
285 const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) {
286 yield* Effect.annotateCurrentSpan({ reason });
287 if (yield* Ref.get(desktopState.quitting)) return false;
288 if (!(yield* Ref.get(updaterConfiguredRef))) return false;
289 if (yield* Ref.get(updateCheckInFlightRef)) return false;
290
291 const state = yield* Ref.get(updateStateRef);
292 if (state.status === "downloading" || state.status === "downloaded") {
293 yield* logUpdaterInfo("skipping update check while update is active", {
294 reason,
295 status: state.status,
296 });
297 return false;
298 }
299
300 yield* Ref.set(updateCheckInFlightRef, true);
301 const checkedAt = yield* currentIsoTimestamp;
302 yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
303 yield* logUpdaterInfo("checking for updates", { reason });
304
305 return yield* electronUpdater.checkForUpdates.pipe(
306 Effect.as(true),
307 Effect.catch(
308 Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) {
309 const failedAt = yield* currentIsoTimestamp;
310 yield* updateState((current) =>
311 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
312 );
313 yield* logUpdaterError("failed to check for updates", { message: error.message });
314 return true;
315 }),
316 ),
317 Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),
318 );
319 });
320
321 const downloadAvailableUpdate = Effect.gen(function* () {
322 const state = yield* Ref.get(updateStateRef);
323 if (
324 !(yield* Ref.get(updaterConfiguredRef)) ||
325 (yield* Ref.get(updateDownloadInFlightRef)) ||
326 state.status !== "available"
327 ) {
328 return { accepted: false, completed: false };
329 }
330
331 yield* Ref.set(updateDownloadInFlightRef, true);
332 return yield* Effect.gen(function* () {
333 yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));
334 yield* electronUpdater.setDisableDifferentialDownload(
335 isArm64HostRunningIntelBuild(environment.runtimeInfo),
336 );
337 yield* logUpdaterInfo("downloading update");
338 yield* electronUpdater.downloadUpdate;
339 return { accepted: true, completed: true };
340 }).pipe(
341 Effect.catch(
342 Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) {
343 yield* updateState((current) =>
344 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
345 );
346 yield* logUpdaterError("failed to download update", { message: error.message });
347 return { accepted: true, completed: false };
348 }),
349 ),
350 Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),
351 );
352 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
353
354 const installDownloadedUpdate = Effect.gen(function* () {
355 const state = yield* Ref.get(updateStateRef);
356 if (
357 (yield* Ref.get(desktopState.quitting)) ||
358 !(yield* Ref.get(updaterConfiguredRef)) ||
359 state.status !== "downloaded"
360 ) {
361 return { accepted: false, completed: false };
362 }
363
364 yield* Ref.set(desktopState.quitting, true);
365 yield* Ref.set(updateInstallInFlightRef, true);
366
367 return yield* Effect.gen(function* () {
368 yield* backendManager.stop({ timeout: Duration.seconds(5) });
369 yield* electronWindow.destroyAll;
370 yield* electronUpdater.quitAndInstall({
371 isSilent: true,
372 isForceRunAfter: true,
373 });
374 return { accepted: true, completed: false };
375 }).pipe(
376 Effect.catch(
377 Effect.fn("desktop.updates.handleInstallFailure")(function* (error) {
378 yield* Ref.set(updateInstallInFlightRef, false);
379 yield* updateState((current) =>
380 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
381 );
382 yield* Ref.set(desktopState.quitting, false);
383 yield* logUpdaterError("failed to install update", { message: error.message });
384 return { accepted: true, completed: false };
385 }),
386 ),
387 );
388 }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));
389
390 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
391 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
392 Effect.andThen(checkForUpdates("startup")),
393 Effect.catchCause((cause) =>
394 logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }),
395 ),
396 Effect.forkScoped,
397 );
398 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
399 Effect.andThen(checkForUpdates("poll")),
400 Effect.forever,
401 Effect.catchCause((cause) =>
402 logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }),
403 ),
404 Effect.forkScoped,
405 );
406 }).pipe(Effect.withSpan("desktop.updates.startPollers"));
407
408 const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* (
409 raw: unknown,
410 ) {
411 yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe(
412 Effect.flatMap(
413 Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {
414 const state = yield* Ref.get(updateStateRef);
415 if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) {
416 yield* logUpdaterInfo("ignoring update that does not match selected channel", {
417 version: info.version,
418 channel: state.channel,
419 });
420 const checkedAt = yield* currentIsoTimestamp;
421 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
422 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
423 return;
424 }
425
426 const checkedAt = yield* currentIsoTimestamp;
427 yield* setState(
428 reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt),
429 );
430 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
431 yield* logUpdaterInfo("update available", { version: info.version });
432 }),
433 ),
434 Effect.catchCause((cause) =>
435 logUpdaterWarning("ignored malformed update-available event", {
436 cause: Cause.pretty(cause),
437 }),
438 ),
439 );
440 });
441
442 const handleUpdateNotAvailable = Effect.gen(function* () {
443 const checkedAt = yield* currentIsoTimestamp;
444 const state = yield* Ref.get(updateStateRef);
445 yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
446 yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
447 yield* logUpdaterInfo("no updates available");
448 }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));
449
450 const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (
451 error: unknown,
452 ) {
453 const message = error instanceof Error ? error.message : String(error);
454 if (yield* Ref.get(updateInstallInFlightRef)) {
455 yield* Ref.set(updateInstallInFlightRef, false);
456 yield* Ref.set(desktopState.quitting, false);
457 yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
458 yield* logUpdaterError("updater error", { message });
459 return;
460 }
461
462 if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) {
463 const errorContext = yield* resolveUpdaterErrorContext;
464 const checkedAt = yield* currentIsoTimestamp;
465 yield* updateState((current) => ({
466 ...current,
467 status: "error",
468 message,
469 checkedAt,
470 downloadPercent: null,
471 errorContext,
472 canRetry: getCanRetryFromState(current),
473 }));
474 }
475
476 yield* logUpdaterError("updater error", { message });
477 });
478
479 const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (
480 raw: unknown,
481 ) {
482 yield* Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe(
483 Effect.flatMap(
484 Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) {
485 const state = yield* Ref.get(updateStateRef);
486 const percent = Math.floor(progress.percent);
487 if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) {
488 yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent));
489 }
490 const milestone = percent - (percent % 10);
491 const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef);
492 if (milestone > lastLoggedMilestone) {
493 yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone);
494 yield* logUpdaterInfo("download progress", { percent });
495 }
496 }),
497 ),
498 Effect.catchCause((cause) =>
499 logUpdaterWarning("ignored malformed download-progress event", {
500 cause: Cause.pretty(cause),
501 }),
502 ),
503 );
504 });
505
506 const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* (
507 raw: unknown,
508 ) {
509 yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe(
510 Effect.flatMap(
511 Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
512 const state = yield* Ref.get(updateStateRef);
513 yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
514 yield* logUpdaterInfo("update downloaded", { version: info.version });
515 }),
516 ),
517 Effect.catchCause((cause) =>
518 logUpdaterWarning("ignored malformed update-downloaded event", {
519 cause: Cause.pretty(cause),
520 }),
521 ),
522 );
523 });
524
525 return DesktopUpdates.of({
526 getState: Ref.get(updateStateRef),
527 emitState,
528 disabledReason: resolveDisabledReason,
529 configure: Effect.gen(function* () {
530 const context = yield* Effect.context<never>();
531 const runEffect = (effect: Effect.Effect<void>) => {
532 void Effect.runPromiseWith(context)(effect);
533 };
534
535 const appUpdateYmlConfig = yield* readAppUpdateYml;
536 yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig);
537
538 if (config.mockUpdates) {
539 yield* electronUpdater.setFeedURL({
540 provider: "generic",
541 url: `http://localhost:${config.mockUpdateServerPort}`,
542 } as ElectronUpdater.ElectronUpdaterFeedUrl);
543 }
544
545 const settings = yield* desktopSettings.get;
546 const enabled = yield* shouldEnableAutoUpdates;
547 yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment));
548 if (!enabled) {
549 return;
550 }
551 yield* Ref.set(updaterConfiguredRef, true);
552
553 yield* electronUpdater.setAutoDownload(false);
554 yield* electronUpdater.setAutoInstallOnAppQuit(false);
555 yield* applyAutoUpdaterChannel(settings.updateChannel);
556 yield* electronUpdater.setDisableDifferentialDownload(
557 isArm64HostRunningIntelBuild(environment.runtimeInfo),
558 );
559
560 if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) {
561 yield* logUpdaterInfo(
562 "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages",
563 );
564 }
565
566 yield* electronUpdater.on("checking-for-update", () => {
567 runEffect(
568 logUpdaterInfo("looking for updates").pipe(
569 Effect.withSpan("desktop.updates.handleCheckingForUpdate"),
570 ),
571 );
572 });
573 yield* electronUpdater.on("update-available", (info: unknown) => {
574 runEffect(handleUpdateAvailable(info));
575 });
576 yield* electronUpdater.on("update-not-available", () => {
577 runEffect(handleUpdateNotAvailable);
578 });
579 yield* electronUpdater.on("error", (error: unknown) => {
580 runEffect(handleUpdaterError(error));
581 });
582 yield* electronUpdater.on("download-progress", (progress: unknown) => {
583 runEffect(handleDownloadProgress(progress));
584 });
585 yield* electronUpdater.on("update-downloaded", (info: unknown) => {
586 runEffect(handleUpdateDownloaded(info));
587 });
588
589 yield* startUpdatePollers;
590 }).pipe(Effect.withSpan("desktop.updates.configure")),
591 setChannel: Effect.fn("desktop.updates.setChannel")(function* (
592 nextChannel: DesktopUpdateChannel,
593 ) {
594 yield* Effect.annotateCurrentSpan({ channel: nextChannel });
595 const activeAction = yield* activeUpdateAction;
596 if (Option.isSome(activeAction)) {
597 return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value });
598 }
599
600 const state = yield* Ref.get(updateStateRef);
601 if (nextChannel === state.channel) {
602 return state;
603 }
604
605 yield* desktopSettings
606 .setUpdateChannel(nextChannel)
607 .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause })));
608
609 const enabled = yield* shouldEnableAutoUpdates;
610 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
611
612 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
613 return yield* Ref.get(updateStateRef);
614 }
615
616 yield* applyAutoUpdaterChannel(nextChannel);
617 const allowDowngrade = yield* electronUpdater.allowDowngrade;
618 yield* electronUpdater.setAllowDowngrade(true);
619 yield* checkForUpdates("channel-change").pipe(
620 Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
621 );
622 return yield* Ref.get(updateStateRef);
623 }),
624 check: Effect.fn("desktop.updates.check")(function* (reason: string) {
625 yield* Effect.annotateCurrentSpan({ reason });
626 if (!(yield* Ref.get(updaterConfiguredRef))) {
627 return {
628 checked: false,
629 state: yield* Ref.get(updateStateRef),
630 };
631 }
632 const checked = yield* checkForUpdates(reason);
633 return {
634 checked,
635 state: yield* Ref.get(updateStateRef),
636 };
637 }),
638 download: Effect.gen(function* () {
639 const result = yield* downloadAvailableUpdate;
640 return {
641 accepted: result.accepted,
642 completed: result.completed,
643 state: yield* Ref.get(updateStateRef),
644 };
645 }).pipe(Effect.withSpan("desktop.updates.download")),
646 install: Effect.gen(function* () {
647 if (yield* Ref.get(desktopState.quitting)) {
648 return {
649 accepted: false,
650 completed: false,
651 state: yield* Ref.get(updateStateRef),
652 };
653 }
654 const result = yield* installDownloadedUpdate;
655 return {
656 accepted: result.accepted,
657 completed: result.completed,
658 state: yield* Ref.get(updateStateRef),
659 };
660 }).pipe(Effect.withSpan("desktop.updates.install")),
661 });
662});
663
664export const layer = Layer.effect(DesktopUpdates, make);
apps/desktop/src/updates/updateChannels.ts created+11
...@@ -0,0 +1,11 @@
1import type { DesktopUpdateChannel } from "@t3tools/contracts";
2
3const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
4
5export function isNightlyDesktopVersion(version: string): boolean {
6 return NIGHTLY_VERSION_PATTERN.test(version);
7}
8
9export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
11}
apps/desktop/src/updates/updateMachine.test.ts created+140
...@@ -0,0 +1,140 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 createInitialDesktopUpdateState,
5 reduceDesktopUpdateStateOnCheckFailure,
6 reduceDesktopUpdateStateOnCheckStart,
7 reduceDesktopUpdateStateOnDownloadComplete,
8 reduceDesktopUpdateStateOnDownloadFailure,
9 reduceDesktopUpdateStateOnDownloadProgress,
10 reduceDesktopUpdateStateOnDownloadStart,
11 reduceDesktopUpdateStateOnInstallFailure,
12 reduceDesktopUpdateStateOnNoUpdate,
13 reduceDesktopUpdateStateOnUpdateAvailable,
14} from "./updateMachine.ts";
15
16const runtimeInfo = {
17 hostArch: "x64",
18 appArch: "x64",
19 runningUnderArm64Translation: false,
20} as const;
21
22describe("updateMachine", () => {
23 it("clears transient errors when a check starts", () => {
24 const state = reduceDesktopUpdateStateOnCheckStart(
25 {
26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
27 enabled: true,
28 status: "error",
29 message: "network",
30 errorContext: "check",
31 canRetry: true,
32 },
33 "2026-03-04T00:00:00.000Z",
34 );
35
36 expect(state.status).toBe("checking");
37 expect(state.message).toBeNull();
38 expect(state.errorContext).toBeNull();
39 expect(state.canRetry).toBe(false);
40 });
41
42 it("records a check failure without exposing an action", () => {
43 const state = reduceDesktopUpdateStateOnCheckFailure(
44 {
45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
46 enabled: true,
47 status: "checking",
48 },
49 "network unavailable",
50 "2026-03-04T00:00:00.000Z",
51 );
52
53 expect(state.status).toBe("error");
54 expect(state.errorContext).toBe("check");
55 expect(state.canRetry).toBe(true);
56 });
57
58 it("preserves available version on download failure for retry", () => {
59 const state = reduceDesktopUpdateStateOnDownloadFailure(
60 {
61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
62 enabled: true,
63 status: "downloading",
64 availableVersion: "1.1.0",
65 downloadPercent: 43,
66 },
67 "checksum mismatch",
68 );
69
70 expect(state.status).toBe("available");
71 expect(state.availableVersion).toBe("1.1.0");
72 expect(state.errorContext).toBe("download");
73 expect(state.canRetry).toBe(true);
74 });
75
76 it("transitions to downloaded and then preserves install retry state", () => {
77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
78 {
79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
80 enabled: true,
81 status: "downloading",
82 availableVersion: "1.1.0",
83 },
84 "1.1.0",
85 );
86 const failedInstall = reduceDesktopUpdateStateOnInstallFailure(
87 downloaded,
88 "backend shutdown timed out",
89 );
90
91 expect(downloaded.status).toBe("downloaded");
92 expect(downloaded.downloadedVersion).toBe("1.1.0");
93 expect(failedInstall.status).toBe("downloaded");
94 expect(failedInstall.errorContext).toBe("install");
95 expect(failedInstall.canRetry).toBe(true);
96 });
97
98 it("clears stale download state when no update is available", () => {
99 const state = reduceDesktopUpdateStateOnNoUpdate(
100 {
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
102 enabled: true,
103 status: "error",
104 availableVersion: "1.1.0",
105 downloadedVersion: "1.1.0",
106 message: "old failure",
107 errorContext: "download",
108 canRetry: true,
109 },
110 "2026-03-04T00:00:00.000Z",
111 );
112
113 expect(state.status).toBe("up-to-date");
114 expect(state.availableVersion).toBeNull();
115 expect(state.downloadedVersion).toBeNull();
116 expect(state.message).toBeNull();
117 expect(state.errorContext).toBeNull();
118 });
119
120 it("tracks available, download start, and progress cleanly", () => {
121 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122 {
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
124 enabled: true,
125 status: "checking",
126 },
127 "1.1.0",
128 "2026-03-04T00:00:00.000Z",
129 );
130 const downloading = reduceDesktopUpdateStateOnDownloadStart(available);
131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132
133 expect(available.status).toBe("available");
134 expect(available.channel).toBe("latest");
135 expect(downloading.status).toBe("downloading");
136 expect(downloading.downloadPercent).toBe(0);
137 expect(progress.downloadPercent).toBe(55.5);
138 expect(progress.errorContext).toBeNull();
139 });
140});
apps/desktop/src/updates/updateMachine.ts created+174
...@@ -0,0 +1,174 @@
1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateChannel,
4 DesktopUpdateState,
5} from "@t3tools/contracts";
6
7export function nextStatusAfterDownloadFailure(
8 currentState: DesktopUpdateState,
9): DesktopUpdateState["status"] {
10 return currentState.availableVersion ? "available" : "error";
11}
12
13export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean {
14 return currentState.availableVersion !== null;
15}
16
17export function createInitialDesktopUpdateState(
18 currentVersion: string,
19 runtimeInfo: DesktopRuntimeInfo,
20 channel: DesktopUpdateChannel,
21): DesktopUpdateState {
22 return {
23 enabled: false,
24 status: "disabled",
25 channel,
26 currentVersion,
27 hostArch: runtimeInfo.hostArch,
28 appArch: runtimeInfo.appArch,
29 runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation,
30 availableVersion: null,
31 downloadedVersion: null,
32 downloadPercent: null,
33 checkedAt: null,
34 message: null,
35 errorContext: null,
36 canRetry: false,
37 };
38}
39
40export function reduceDesktopUpdateStateOnCheckStart(
41 state: DesktopUpdateState,
42 checkedAt: string,
43): DesktopUpdateState {
44 return {
45 ...state,
46 status: "checking",
47 checkedAt,
48 message: null,
49 downloadPercent: null,
50 errorContext: null,
51 canRetry: false,
52 };
53}
54
55export function reduceDesktopUpdateStateOnCheckFailure(
56 state: DesktopUpdateState,
57 message: string,
58 checkedAt: string,
59): DesktopUpdateState {
60 return {
61 ...state,
62 status: "error",
63 message,
64 checkedAt,
65 downloadPercent: null,
66 errorContext: "check",
67 canRetry: true,
68 };
69}
70
71export function reduceDesktopUpdateStateOnUpdateAvailable(
72 state: DesktopUpdateState,
73 version: string,
74 checkedAt: string,
75): DesktopUpdateState {
76 return {
77 ...state,
78 status: "available",
79 availableVersion: version,
80 downloadedVersion: null,
81 downloadPercent: null,
82 checkedAt,
83 message: null,
84 errorContext: null,
85 canRetry: false,
86 };
87}
88
89export function reduceDesktopUpdateStateOnNoUpdate(
90 state: DesktopUpdateState,
91 checkedAt: string,
92): DesktopUpdateState {
93 return {
94 ...state,
95 status: "up-to-date",
96 availableVersion: null,
97 downloadedVersion: null,
98 downloadPercent: null,
99 checkedAt,
100 message: null,
101 errorContext: null,
102 canRetry: false,
103 };
104}
105
106export function reduceDesktopUpdateStateOnDownloadStart(
107 state: DesktopUpdateState,
108): DesktopUpdateState {
109 return {
110 ...state,
111 status: "downloading",
112 downloadPercent: 0,
113 message: null,
114 errorContext: null,
115 canRetry: false,
116 };
117}
118
119export function reduceDesktopUpdateStateOnDownloadFailure(
120 state: DesktopUpdateState,
121 message: string,
122): DesktopUpdateState {
123 return {
124 ...state,
125 status: nextStatusAfterDownloadFailure(state),
126 message,
127 downloadPercent: null,
128 errorContext: "download",
129 canRetry: getCanRetryAfterDownloadFailure(state),
130 };
131}
132
133export function reduceDesktopUpdateStateOnDownloadProgress(
134 state: DesktopUpdateState,
135 percent: number,
136): DesktopUpdateState {
137 return {
138 ...state,
139 status: "downloading",
140 downloadPercent: percent,
141 message: null,
142 errorContext: null,
143 canRetry: false,
144 };
145}
146
147export function reduceDesktopUpdateStateOnDownloadComplete(
148 state: DesktopUpdateState,
149 version: string,
150): DesktopUpdateState {
151 return {
152 ...state,
153 status: "downloaded",
154 availableVersion: version,
155 downloadedVersion: version,
156 downloadPercent: 100,
157 message: null,
158 errorContext: null,
159 canRetry: true,
160 };
161}
162
163export function reduceDesktopUpdateStateOnInstallFailure(
164 state: DesktopUpdateState,
165 message: string,
166): DesktopUpdateState {
167 return {
168 ...state,
169 status: "downloaded",
170 message,
171 errorContext: "install",
172 canRetry: true,
173 };
174}
apps/desktop/src/window/DesktopApplicationMenu.test.ts created+132
...@@ -0,0 +1,132 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7
8import type * as Electron from "electron";
9
10import * as ElectronApp from "../electron/ElectronApp.ts";
11import * as ElectronDialog from "../electron/ElectronDialog.ts";
12import * as ElectronMenu from "../electron/ElectronMenu.ts";
13import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts";
14import * as DesktopConfig from "../app/DesktopConfig.ts";
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
17import * as DesktopWindow from "./DesktopWindow.ts";
18
19const environmentInput = {
20 dirname: "/repo/apps/desktop/dist-electron",
21 homeDirectory: "/Users/alice",
22 platform: "linux",
23 processArch: "arm64",
24 appVersion: "1.2.3",
25 appPath: "/repo",
26 isPackaged: false,
27 resourcesPath: "/repo/resources",
28 runningUnderArm64Translation: false,
29} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
30
31const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
32 metadata: Effect.die("unexpected metadata read"),
33 name: Effect.succeed("T3 Code"),
34 whenReady: Effect.void,
35 quit: Effect.void,
36 exit: () => Effect.void,
37 relaunch: () => Effect.void,
38 setPath: () => Effect.void,
39 setName: () => Effect.void,
40 setAboutPanelOptions: () => Effect.void,
41 setAppUserModelId: () => Effect.void,
42 setDesktopName: () => Effect.void,
43 setDockIcon: () => Effect.void,
44 appendCommandLineSwitch: () => Effect.void,
45 on: () => Effect.void,
46} satisfies ElectronApp.ElectronAppShape);
47
48const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
49 pickFolder: () => Effect.succeed(Option.none()),
50 confirm: () => Effect.succeed(false),
51 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
52 showErrorBox: () => Effect.void,
53} satisfies ElectronDialog.ElectronDialogShape);
54
55const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
56 getState: Effect.die("unexpected getState"),
57 emitState: Effect.void,
58 disabledReason: Effect.succeed(Option.none()),
59 configure: Effect.void,
60 setChannel: () => Effect.die("unexpected setChannel"),
61 check: () => Effect.die("unexpected check"),
62 download: Effect.die("unexpected download"),
63 install: Effect.die("unexpected install"),
64} satisfies DesktopUpdates.DesktopUpdatesShape);
65
66const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
67 Layer.succeed(DesktopWindow.DesktopWindow, {
68 createMain: Effect.die("unexpected createMain"),
69 ensureMain: Effect.die("unexpected ensureMain"),
70 revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
71 activate: Effect.void,
72 createMainIfBackendReady: Effect.void,
73 handleBackendReady: Effect.void,
74 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
75 syncAppearance: Effect.void,
76 } satisfies DesktopWindow.DesktopWindowShape);
77
78const makeElectronMenuLayer = (
79 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
80) =>
81 Layer.succeed(ElectronMenu.ElectronMenu, {
82 setApplicationMenu: (template) =>
83 Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid),
84 popupTemplate: () => Effect.void,
85 showContextMenu: () => Effect.succeed(Option.none()),
86 } satisfies ElectronMenu.ElectronMenuShape);
87
88describe("DesktopApplicationMenu", () => {
89 it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
90 Effect.gen(function* () {
91 const selectedAction = yield* Deferred.make<string>();
92 const applicationMenuTemplate =
93 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
94
95 yield* Effect.gen(function* () {
96 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
97 yield* menu.configure;
98 }).pipe(
99 Effect.provide(
100 DesktopApplicationMenu.layer.pipe(
101 Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
102 Layer.provideMerge(makeDesktopWindowLayer(selectedAction)),
103 Layer.provideMerge(desktopUpdatesLayer),
104 Layer.provideMerge(electronDialogLayer),
105 Layer.provideMerge(electronAppLayer),
106 Layer.provideMerge(
107 DesktopEnvironment.layer(environmentInput).pipe(
108 Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
109 ),
110 ),
111 ),
112 ),
113 );
114
115 const template = yield* Deferred.await(applicationMenuTemplate);
116 const fileMenu = template.find((item) => item.label === "File");
117 assert.isDefined(fileMenu);
118 if (!Array.isArray(fileMenu.submenu)) {
119 throw new Error("Expected File menu submenu to be an array.");
120 }
121 const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings...");
122 assert.isDefined(settingsItem);
123 const settingsClick = settingsItem.click;
124 if (typeof settingsClick !== "function") {
125 throw new Error("Expected Settings menu item to have a click handler.");
126 }
127
128 settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
129 assert.equal(yield* Deferred.await(selectedAction), "open-settings");
130 }),
131 );
132});
apps/desktop/src/window/DesktopApplicationMenu.ts created+212
...@@ -0,0 +1,212 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import type * as Electron from "electron";
8
9import * as DesktopObservability from "../app/DesktopObservability.ts";
10import * as ElectronApp from "../electron/ElectronApp.ts";
11import * as ElectronDialog from "../electron/ElectronDialog.ts";
12import * as ElectronMenu from "../electron/ElectronMenu.ts";
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
15import * as DesktopWindow from "./DesktopWindow.ts";
16
17export interface DesktopApplicationMenuShape {
18 readonly configure: Effect.Effect<void>;
19}
20
21export class DesktopApplicationMenu extends Context.Service<
22 DesktopApplicationMenu,
23 DesktopApplicationMenuShape
24>()("t3/desktop/ApplicationMenu") {}
25
26type DesktopApplicationMenuRuntimeServices =
27 | DesktopUpdates.DesktopUpdates
28 | DesktopWindow.DesktopWindow
29 | ElectronDialog.ElectronDialog;
30
31const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater");
32
33const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu");
34
35const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* (
36 action: string,
37): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
38 const desktopWindow = yield* DesktopWindow.DesktopWindow;
39 yield* desktopWindow.dispatchMenuAction(action);
40});
41
42const checkForUpdatesFromMenu: Effect.Effect<
43 void,
44 never,
45 DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog
46> = Effect.gen(function* () {
47 const updates = yield* DesktopUpdates.DesktopUpdates;
48 const electronDialog = yield* ElectronDialog.ElectronDialog;
49 const result = yield* updates.check("menu");
50 const updateState = result.state;
51
52 if (updateState.status === "up-to-date") {
53 yield* electronDialog.showMessageBox({
54 type: "info",
55 title: "You're up to date!",
56 message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
57 buttons: ["OK"],
58 });
59 } else if (updateState.status === "error") {
60 yield* electronDialog.showMessageBox({
61 type: "warning",
62 title: "Update check failed",
63 message: "Could not check for updates.",
64 detail: updateState.message ?? "An unknown error occurred. Please try again later.",
65 buttons: ["OK"],
66 });
67 }
68}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
69
70const handleCheckForUpdatesMenuClick: Effect.Effect<
71 void,
72 DesktopWindow.DesktopWindowError,
73 DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow
74> = Effect.gen(function* () {
75 const updates = yield* DesktopUpdates.DesktopUpdates;
76 const electronDialog = yield* ElectronDialog.ElectronDialog;
77 const disabledReason = yield* updates.disabledReason;
78 if (Option.isSome(disabledReason)) {
79 yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
80 disabledReason: disabledReason.value,
81 });
82 yield* electronDialog.showMessageBox({
83 type: "info",
84 title: "Updates unavailable",
85 message: "Automatic updates are not available right now.",
86 detail: disabledReason.value,
87 buttons: ["OK"],
88 });
89 return;
90 }
91
92 const desktopWindow = yield* DesktopWindow.DesktopWindow;
93 yield* desktopWindow.ensureMain;
94 yield* checkForUpdatesFromMenu;
95}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick"));
96
97const make = Effect.gen(function* () {
98 const electronApp = yield* ElectronApp.ElectronApp;
99 const electronMenu = yield* ElectronMenu.ElectronMenu;
100 const environment = yield* DesktopEnvironment.DesktopEnvironment;
101 const appName = yield* electronApp.name;
102 const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
103 const runPromise = Effect.runPromiseWith(context);
104
105 const runMenuEffect = <E>(
106 action: string,
107 effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>,
108 ) => {
109 void runPromise(
110 effect.pipe(
111 Effect.annotateLogs({ action }),
112 Effect.withSpan("desktop.menu.action"),
113 Effect.catchCause((cause) =>
114 logMenuError("desktop menu action failed", {
115 action,
116 cause: Cause.pretty(cause),
117 }),
118 ),
119 ),
120 );
121 };
122
123 const configure = Effect.gen(function* () {
124 const checkForUpdatesClick = () => {
125 runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
126 };
127 const settingsClick = () => {
128 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
129 };
130 const template: Electron.MenuItemConstructorOptions[] = [];
131
132 if (environment.platform === "darwin") {
133 template.push({
134 label: appName,
135 submenu: [
136 { role: "about" },
137 {
138 label: "Check for Updates...",
139 click: checkForUpdatesClick,
140 },
141 { type: "separator" },
142 {
143 label: "Settings...",
144 accelerator: "CmdOrCtrl+,",
145 click: settingsClick,
146 },
147 { type: "separator" },
148 { role: "services" },
149 { type: "separator" },
150 { role: "hide" },
151 { role: "hideOthers" },
152 { role: "unhide" },
153 { type: "separator" },
154 { role: "quit" },
155 ],
156 });
157 }
158
159 template.push(
160 {
161 label: "File",
162 submenu: [
163 ...(environment.platform === "darwin"
164 ? []
165 : [
166 {
167 label: "Settings...",
168 accelerator: "CmdOrCtrl+,",
169 click: settingsClick,
170 },
171 { type: "separator" as const },
172 ]),
173 { role: environment.platform === "darwin" ? "close" : "quit" },
174 ],
175 },
176 { role: "editMenu" },
177 {
178 label: "View",
179 submenu: [
180 { role: "reload" },
181 { role: "forceReload" },
182 { role: "toggleDevTools" },
183 { type: "separator" },
184 { role: "resetZoom" },
185 { role: "zoomIn", accelerator: "CmdOrCtrl+=" },
186 { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
187 { role: "zoomOut" },
188 { type: "separator" },
189 { role: "togglefullscreen" },
190 ],
191 },
192 { role: "windowMenu" },
193 {
194 role: "help",
195 submenu: [
196 {
197 label: "Check for Updates...",
198 click: checkForUpdatesClick,
199 },
200 ],
201 },
202 );
203
204 yield* electronMenu.setApplicationMenu(template);
205 }).pipe(Effect.withSpan("desktop.menu.configure"));
206
207 return DesktopApplicationMenu.of({
208 configure,
209 });
210});
211
212export const layer = Layer.effect(DesktopApplicationMenu, make);
apps/desktop/src/window/DesktopWindow.test.ts created+180
...@@ -0,0 +1,180 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7
8import type * as Electron from "electron";
9import { vi } from "vitest";
10
11import * as DesktopAssets from "../app/DesktopAssets.ts";
12import * as DesktopConfig from "../app/DesktopConfig.ts";
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14import * as DesktopState from "../app/DesktopState.ts";
15import * as ElectronMenu from "../electron/ElectronMenu.ts";
16import * as ElectronShell from "../electron/ElectronShell.ts";
17import * as ElectronTheme from "../electron/ElectronTheme.ts";
18import * as ElectronWindow from "../electron/ElectronWindow.ts";
19import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
20import * as DesktopWindow from "./DesktopWindow.ts";
21
22const environmentInput = {
23 dirname: "/repo/apps/desktop/dist-electron",
24 homeDirectory: "/Users/alice",
25 platform: "darwin",
26 processArch: "arm64",
27 appVersion: "1.2.3",
28 appPath: "/repo",
29 isPackaged: false,
30 resourcesPath: "/repo/resources",
31 runningUnderArm64Translation: false,
32} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
33
34function makeFakeBrowserWindow() {
35 const webContents = {
36 copyImageAt: vi.fn(),
37 isLoadingMainFrame: vi.fn(() => false),
38 on: vi.fn(),
39 once: vi.fn(),
40 openDevTools: vi.fn(),
41 replaceMisspelling: vi.fn(),
42 send: vi.fn(),
43 setWindowOpenHandler: vi.fn(),
44 };
45
46 const window = {
47 focus: vi.fn(),
48 isDestroyed: vi.fn(() => false),
49 isMinimized: vi.fn(() => false),
50 isVisible: vi.fn(() => true),
51 loadURL: vi.fn(() => Promise.resolve()),
52 on: vi.fn(),
53 once: vi.fn(),
54 restore: vi.fn(),
55 setBackgroundColor: vi.fn(),
56 setTitle: vi.fn(),
57 setTitleBarOverlay: vi.fn(),
58 show: vi.fn(),
59 webContents,
60 };
61
62 return {
63 window: window as unknown as Electron.BrowserWindow,
64 loadURL: window.loadURL,
65 openDevTools: webContents.openDevTools,
66 };
67}
68
69const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
70 iconPaths: Effect.succeed({
71 ico: Option.none<string>(),
72 icns: Option.none<string>(),
73 png: Option.none<string>(),
74 }),
75 resolveResourcePath: () => Effect.succeed(Option.none<string>()),
76} satisfies DesktopAssets.DesktopAssetsShape);
77
78const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {
79 getState: Effect.die("unexpected getState"),
80 backendConfig: Effect.succeed({
81 port: 3773,
82 bindHost: "127.0.0.1",
83 httpBaseUrl: new URL("http://127.0.0.1:3773"),
84 tailscaleServeEnabled: false,
85 tailscaleServePort: 443,
86 }),
87 configureFromSettings: () => Effect.die("unexpected configureFromSettings"),
88 setMode: () => Effect.die("unexpected setMode"),
89 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
90 getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"),
91} satisfies DesktopServerExposure.DesktopServerExposureShape);
92
93const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, {
94 setApplicationMenu: () => Effect.void,
95 popupTemplate: () => Effect.void,
96 showContextMenu: () => Effect.succeed(Option.none()),
97} satisfies ElectronMenu.ElectronMenuShape);
98
99const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, {
100 openExternal: () => Effect.succeed(true),
101 copyText: () => Effect.void,
102} satisfies ElectronShell.ElectronShellShape);
103
104const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
105 shouldUseDarkColors: Effect.succeed(false),
106 setSource: () => Effect.void,
107 onUpdated: () => Effect.void,
108} satisfies ElectronTheme.ElectronThemeShape);
109
110const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
111 Layer.provide(
112 Layer.mergeAll(
113 NodeServices.layer,
114 DesktopConfig.layerTest({
115 T3CODE_PORT: "3773",
116 VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
117 }),
118 ),
119 ),
120);
121
122function makeTestLayer(input: {
123 readonly window: Electron.BrowserWindow;
124 readonly createCount: Ref.Ref<number>;
125 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;
126}) {
127 const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
128 create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)),
129 main: Ref.get(input.mainWindow),
130 currentMainOrFirst: Ref.get(input.mainWindow),
131 focusedMainOrFirst: Ref.get(input.mainWindow),
132 setMain: (window) => Ref.set(input.mainWindow, Option.some(window)),
133 clearMain: () => Ref.set(input.mainWindow, Option.none()),
134 reveal: () => Effect.void,
135 sendAll: () => Effect.void,
136 destroyAll: Effect.void,
137 syncAllAppearance: (sync) => sync(input.window),
138 } satisfies ElectronWindow.ElectronWindowShape);
139
140 return DesktopWindow.layer.pipe(
141 Layer.provide(
142 Layer.mergeAll(
143 desktopAssetsLayer,
144 desktopEnvironmentLayer,
145 desktopServerExposureLayer,
146 DesktopState.layer,
147 electronMenuLayer,
148 electronShellLayer,
149 electronThemeLayer,
150 electronWindowLayer,
151 ),
152 ),
153 );
154}
155
156describe("DesktopWindow", () => {
157 it.effect("does not open a development window until the backend is ready", () =>
158 Effect.gen(function* () {
159 const fakeWindow = makeFakeBrowserWindow();
160 const createCount = yield* Ref.make(0);
161 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
162 const layer = makeTestLayer({
163 window: fakeWindow.window,
164 createCount,
165 mainWindow,
166 });
167
168 yield* Effect.gen(function* () {
169 const desktopWindow = yield* DesktopWindow.DesktopWindow;
170 yield* desktopWindow.activate;
171 assert.equal(yield* Ref.get(createCount), 0);
172
173 yield* desktopWindow.handleBackendReady;
174 assert.equal(yield* Ref.get(createCount), 1);
175 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]);
176 assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
177 }).pipe(Effect.provide(layer));
178 }),
179 );
180});
apps/desktop/src/window/DesktopWindow.ts created+368
...@@ -0,0 +1,368 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7
8import type * as Electron from "electron";
9
10import * as DesktopAssets from "../app/DesktopAssets.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import * as DesktopObservability from "../app/DesktopObservability.ts";
13import * as DesktopState from "../app/DesktopState.ts";
14import * as ElectronMenu from "../electron/ElectronMenu.ts";
15import * as ElectronShell from "../electron/ElectronShell.ts";
16import * as ElectronTheme from "../electron/ElectronTheme.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";
18import * as IpcChannels from "../ipc/channels.ts";
19import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts";
20
21const TITLEBAR_HEIGHT = 40;
22const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
23const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
24const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
25
26type WindowTitleBarOptions = Pick<
27 Electron.BrowserWindowConstructorOptions,
28 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
29>;
30
31type DesktopWindowRuntimeServices =
32 | DesktopEnvironment.DesktopEnvironment
33 | DesktopAssets.DesktopAssets
34 | DesktopServerExposure.DesktopServerExposure
35 | DesktopState.DesktopState
36 | ElectronMenu.ElectronMenu
37 | ElectronShell.ElectronShell
38 | ElectronTheme.ElectronTheme
39 | ElectronWindow.ElectronWindow;
40
41export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError(
42 "DesktopWindowDevServerUrlMissingError",
43)<{}> {
44 override get message() {
45 return "VITE_DEV_SERVER_URL is required in desktop development.";
46 }
47}
48
49export type DesktopWindowError =
50 | DesktopWindowDevServerUrlMissingError
51 | ElectronWindow.ElectronWindowCreateError;
52
53export interface DesktopWindowShape {
54 readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
55 readonly ensureMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
56 readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
57 readonly activate: Effect.Effect<void, DesktopWindowError>;
58 readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>;
59 readonly handleBackendReady: Effect.Effect<void, DesktopWindowError>;
60 readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;
61 readonly syncAppearance: Effect.Effect<void>;
62}
63
64export class DesktopWindow extends Context.Service<DesktopWindow, DesktopWindowShape>()(
65 "t3/desktop/Window",
66) {}
67
68const { logInfo: logWindowInfo, logWarning: logWindowWarning } =
69 DesktopObservability.makeComponentLogger("desktop-window");
70
71function resolveDesktopDevServerUrl(
72 environment: DesktopEnvironment.DesktopEnvironmentShape,
73): Effect.Effect<string, DesktopWindowDevServerUrlMissingError> {
74 return Option.match(environment.devServerUrl, {
75 onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()),
76 onSome: (url) => Effect.succeed(url.href),
77 });
78}
79
80function getIconOption(
81 iconPaths: DesktopAssets.DesktopIconPaths,
82): { icon: string } | Record<string, never> {
83 if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle
84 const ext = process.platform === "win32" ? "ico" : "png";
85 return Option.match(iconPaths[ext], {
86 onNone: () => ({}),
87 onSome: (icon) => ({ icon }),
88 });
89}
90
91function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {
92 return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
93}
94
95function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions {
96 if (process.platform === "darwin") {
97 return {
98 titleBarStyle: "hiddenInset",
99 trafficLightPosition: { x: 16, y: 18 },
100 };
101 }
102
103 return {
104 titleBarStyle: "hidden",
105 titleBarOverlay: {
106 color: TITLEBAR_COLOR,
107 height: TITLEBAR_HEIGHT,
108 symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR,
109 },
110 };
111}
112
113function syncWindowAppearance(
114 window: Electron.BrowserWindow,
115 shouldUseDarkColors: boolean,
116): Effect.Effect<void> {
117 return Effect.sync(() => {
118 if (window.isDestroyed()) {
119 return;
120 }
121
122 window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors));
123 const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors);
124 if (typeof titleBarOverlay === "object") {
125 window.setTitleBarOverlay(titleBarOverlay);
126 }
127 });
128}
129
130type RevealSubscription = (listener: () => void) => void;
131
132function bindFirstRevealTrigger(
133 subscribers: readonly RevealSubscription[],
134 reveal: () => void,
135): void {
136 let revealed = false;
137 const fire = () => {
138 if (revealed) return;
139 revealed = true;
140 reveal();
141 };
142 for (const subscribe of subscribers) {
143 subscribe(fire);
144 }
145}
146
147const make = Effect.gen(function* () {
148 const environment = yield* DesktopEnvironment.DesktopEnvironment;
149 const assets = yield* DesktopAssets.DesktopAssets;
150 const electronMenu = yield* ElectronMenu.ElectronMenu;
151 const electronShell = yield* ElectronShell.ElectronShell;
152 const electronTheme = yield* ElectronTheme.ElectronTheme;
153 const electronWindow = yield* ElectronWindow.ElectronWindow;
154 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
155 const state = yield* DesktopState.DesktopState;
156 const context = yield* Effect.context<DesktopWindowRuntimeServices>();
157 const runPromise = Effect.runPromiseWith(context);
158
159 const createWindow = Effect.fn("desktop.window.createWindow")(function* (
160 backendHttpUrl: URL,
161 ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> {
162 const iconPaths = yield* assets.iconPaths;
163 const iconOption = getIconOption(iconPaths);
164 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
165 const window = yield* electronWindow.create({
166 width: 1100,
167 height: 780,
168 minWidth: 840,
169 minHeight: 620,
170 show: false,
171 autoHideMenuBar: true,
172 backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),
173 ...iconOption,
174 title: environment.displayName,
175 ...getWindowTitleBarOptions(shouldUseDarkColors),
176 webPreferences: {
177 preload: environment.preloadPath,
178 contextIsolation: true,
179 nodeIntegration: false,
180 sandbox: true,
181 },
182 });
183
184 window.webContents.on("context-menu", (event, params) => {
185 event.preventDefault();
186
187 const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
188
189 if (params.misspelledWord) {
190 for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
191 menuTemplate.push({
192 label: suggestion,
193 click: () => window.webContents.replaceMisspelling(suggestion),
194 });
195 }
196 if (params.dictionarySuggestions.length === 0) {
197 menuTemplate.push({ label: "No suggestions", enabled: false });
198 }
199 menuTemplate.push({ type: "separator" });
200 }
201
202 if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
203 menuTemplate.push(
204 {
205 label: "Copy Link",
206 click: () => {
207 void runPromise(electronShell.copyText(params.linkURL));
208 },
209 },
210 { type: "separator" },
211 );
212 }
213
214 if (params.mediaType === "image") {
215 menuTemplate.push({
216 label: "Copy Image",
217 click: () => window.webContents.copyImageAt(params.x, params.y),
218 });
219 menuTemplate.push({ type: "separator" });
220 }
221
222 menuTemplate.push(
223 { role: "cut", enabled: params.editFlags.canCut },
224 { role: "copy", enabled: params.editFlags.canCopy },
225 { role: "paste", enabled: params.editFlags.canPaste },
226 { role: "selectAll", enabled: params.editFlags.canSelectAll },
227 );
228
229 void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
230 });
231
232 window.webContents.setWindowOpenHandler(({ url }) => {
233 if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {
234 void runPromise(electronShell.openExternal(url));
235 }
236 return { action: "deny" };
237 });
238
239 window.on("page-title-updated", (event) => {
240 event.preventDefault();
241 window.setTitle(environment.displayName);
242 });
243 window.webContents.on("did-finish-load", () => {
244 window.setTitle(environment.displayName);
245 });
246 window.webContents.on(
247 "did-fail-load",
248 (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
249 if (!isMainFrame) {
250 return;
251 }
252 void runPromise(
253 logWindowWarning("main window failed to load", {
254 errorCode,
255 errorDescription,
256 url: validatedURL,
257 }),
258 );
259 },
260 );
261 window.webContents.on("render-process-gone", (_event, details) => {
262 void runPromise(
263 logWindowWarning("main window render process gone", {
264 reason: details.reason,
265 exitCode: details.exitCode,
266 }),
267 );
268 });
269
270 const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
271 if (process.platform === "linux") {
272 revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
273 }
274 bindFirstRevealTrigger(revealSubscribers, () => {
275 void runPromise(electronWindow.reveal(window));
276 });
277
278 if (environment.isDevelopment) {
279 const devServerUrl = yield* resolveDesktopDevServerUrl(environment);
280 void window.loadURL(devServerUrl);
281 window.webContents.openDevTools({ mode: "detach" });
282 } else {
283 void window.loadURL(backendHttpUrl.href);
284 }
285
286 window.on("closed", () => {
287 void runPromise(electronWindow.clearMain(Option.some(window)));
288 });
289
290 return window;
291 });
292
293 const createMain = Effect.gen(function* () {
294 const backendConfig = yield* serverExposure.backendConfig;
295 const window = yield* createWindow(backendConfig.httpBaseUrl);
296 yield* electronWindow.setMain(window);
297 yield* logWindowInfo("main window created");
298 return window;
299 }).pipe(Effect.withSpan("desktop.window.createMain"));
300
301 const ensureMain = Effect.gen(function* () {
302 const existingWindow = yield* electronWindow.currentMainOrFirst;
303 if (Option.isSome(existingWindow)) {
304 return existingWindow.value;
305 }
306 return yield* createMain;
307 }).pipe(Effect.withSpan("desktop.window.ensureMain"));
308
309 const revealOrCreateMain = Effect.gen(function* () {
310 const window = yield* ensureMain;
311 yield* electronWindow.reveal(window);
312 return window;
313 }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain"));
314
315 const createMainIfBackendReady = Effect.gen(function* () {
316 const backendReady = yield* Ref.get(state.backendReady);
317 if (!backendReady) return;
318 const existingWindow = yield* electronWindow.currentMainOrFirst;
319 if (Option.isSome(existingWindow)) return;
320 yield* createMain;
321 }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady"));
322
323 return DesktopWindow.of({
324 createMain,
325 ensureMain,
326 revealOrCreateMain,
327 activate: Effect.gen(function* () {
328 const existingWindow = yield* electronWindow.currentMainOrFirst;
329 if (Option.isSome(existingWindow)) {
330 yield* electronWindow.reveal(existingWindow.value);
331 } else {
332 yield* createMainIfBackendReady;
333 }
334 }).pipe(Effect.withSpan("desktop.window.activate")),
335 createMainIfBackendReady,
336 handleBackendReady: Effect.gen(function* () {
337 yield* Ref.set(state.backendReady, true);
338 yield* logWindowInfo("backend ready", { source: "http" });
339 yield* createMainIfBackendReady;
340 }).pipe(Effect.withSpan("desktop.window.handleBackendReady")),
341 dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) {
342 yield* Effect.annotateCurrentSpan({ action });
343 const existingWindow = yield* electronWindow.focusedMainOrFirst;
344 const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain;
345
346 const send = () => {
347 if (targetWindow.isDestroyed()) return;
348 targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action);
349 void runPromise(electronWindow.reveal(targetWindow));
350 };
351
352 if (targetWindow.webContents.isLoadingMainFrame()) {
353 targetWindow.webContents.once("did-finish-load", send);
354 return;
355 }
356
357 send();
358 }),
359 syncAppearance: Effect.gen(function* () {
360 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
361 yield* electronWindow.syncAllAppearance((window) =>
362 syncWindowAppearance(window, shouldUseDarkColors),
363 );
364 }).pipe(Effect.withSpan("desktop.window.syncAppearance")),
365 });
366});
367
368export const layer = Layer.effect(DesktopWindow, make);
apps/desktop/src/windowReveal.test.ts deleted-74
...@@ -1,74 +0,0 @@
1import { EventEmitter } from "node:events";
2
3import { describe, expect, it, vi } from "vitest";
4
5import { bindFirstRevealTrigger } from "./windowReveal.ts";
6
7describe("bindFirstRevealTrigger", () => {
8 it("reveals when the first trigger fires", () => {
9 const window = new EventEmitter();
10 const webContents = new EventEmitter();
11 const reveal = vi.fn();
12
13 bindFirstRevealTrigger(
14 [
15 (fire) => window.once("ready-to-show", fire),
16 (fire) => webContents.once("did-finish-load", fire),
17 ],
18 reveal,
19 );
20
21 window.emit("ready-to-show");
22
23 expect(reveal).toHaveBeenCalledTimes(1);
24 });
25
26 it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => {
27 const window = new EventEmitter();
28 const webContents = new EventEmitter();
29 const reveal = vi.fn();
30
31 bindFirstRevealTrigger(
32 [
33 (fire) => window.once("ready-to-show", fire),
34 (fire) => webContents.once("did-finish-load", fire),
35 ],
36 reveal,
37 );
38
39 webContents.emit("did-finish-load");
40
41 expect(reveal).toHaveBeenCalledTimes(1);
42 });
43
44 it("only reveals once when multiple triggers fire", () => {
45 const window = new EventEmitter();
46 const webContents = new EventEmitter();
47 const reveal = vi.fn();
48
49 bindFirstRevealTrigger(
50 [
51 (fire) => window.once("ready-to-show", fire),
52 (fire) => webContents.once("did-finish-load", fire),
53 ],
54 reveal,
55 );
56
57 webContents.emit("did-finish-load");
58 window.emit("ready-to-show");
59
60 expect(reveal).toHaveBeenCalledTimes(1);
61 });
62
63 it("subscribers using `once` ignore re-emitted events after reveal", () => {
64 const window = new EventEmitter();
65 const reveal = vi.fn();
66
67 bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal);
68
69 window.emit("ready-to-show");
70 window.emit("ready-to-show");
71
72 expect(reveal).toHaveBeenCalledTimes(1);
73 });
74});
apps/desktop/src/windowReveal.ts deleted-28
...@@ -1,28 +0,0 @@
1export type RevealSubscription = (listener: () => void) => void;
2
3/**
4 * Wire a reveal callback to fire exactly once, on whichever of the provided
5 * event subscribers fires first. Each subscriber is responsible for binding
6 * its own event source.
7 *
8 * Used by the desktop main window's first-paint reveal logic. The standard
9 * Electron pattern is to wait for `ready-to-show` before calling `show()`,
10 * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after
11 * `show()` is called, deadlocking that pattern. Subscribing to both
12 * `ready-to-show` and `did-finish-load` (or any other "renderer is alive"
13 * signal) lets the window surface reliably across platforms.
14 */
15export function bindFirstRevealTrigger(
16 subscribers: readonly RevealSubscription[],
17 reveal: () => void,
18): void {
19 let revealed = false;
20 const fire = () => {
21 if (revealed) return;
22 revealed = true;
23 reveal();
24 };
25 for (const subscribe of subscribers) {
26 subscribe(fire);
27 }
28}
apps/desktop/tsconfig.json+13-1
...@@ -3,7 +3,19 @@...@@ -3,7 +3,19 @@
3 "compilerOptions": {3 "compilerOptions": {
4 "composite": true,4 "composite": true,
5 "types": ["node", "electron"],5 "types": ["node", "electron"],
6 "lib": ["ESNext", "DOM", "esnext.disposable"]6 "lib": ["ESNext", "DOM", "esnext.disposable"],
7 "plugins": [
8 {
9 "name": "@effect/language-service",
10 "namespaceImportPackages": ["@effect/platform-node", "effect"],
11 "diagnosticSeverity": {
12 "importFromBarrel": "error",
13 "anyUnknownInErrorContext": "error",
14 "instanceOfSchema": "warning",
15 "deterministicKeys": "warning"
16 }
17 }
18 ]
7 },19 },
8 "include": ["src", "tsdown.config.ts"]20 "include": ["src", "tsdown.config.ts"]
9}21}
apps/server/src/cli/config.test.ts+63-44
...@@ -1,13 +1,33 @@...@@ -1,13 +1,33 @@
1import os from "node:os";1import os from "node:os";
22
3import { assert, expect, it } from "@effect/vitest";3import { assert, expect, it } from "@effect/vitest";
4import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";4import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
55
6import {
7 DesktopBackendBootstrap,
8 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
9} from "@t3tools/contracts";
6import { NetService } from "@t3tools/shared/Net";10import { NetService } from "@t3tools/shared/Net";
7import * as NodeServices from "@effect/platform-node/NodeServices";11import * as NodeServices from "@effect/platform-node/NodeServices";
8import { deriveServerPaths } from "../config.ts";12import { deriveServerPaths } from "../config.ts";
9import { resolveServerConfig } from "./config.ts";13import { resolveServerConfig } from "./config.ts";
1014
15const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
16
17const makeDesktopBootstrap = (
18 overrides: Partial<DesktopBackendBootstrapValue> = {},
19): DesktopBackendBootstrapValue => ({
20 mode: "desktop",
21 noBrowser: true,
22 port: 4888,
23 t3Home: "/tmp/t3-bootstrap-home",
24 host: "127.0.0.1",
25 desktopBootstrapToken: "desktop-bootstrap-token",
26 tailscaleServeEnabled: false,
27 tailscaleServePort: 443,
28 ...overrides,
29});
30
11it.layer(NodeServices.layer)("cli config resolution", (it) => {31it.layer(NodeServices.layer)("cli config resolution", (it) => {
12 const defaultObservabilityConfig = {32 const defaultObservabilityConfig = {
13 traceMinLevel: "Info",33 traceMinLevel: "Info",
...@@ -21,10 +41,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -21,10 +41,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
21 otlpServiceName: "t3-server",41 otlpServiceName: "t3-server",
22 } as const;42 } as const;
2343
24 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {44 const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {
25 const fs = yield* FileSystem.FileSystem;45 const fs = yield* FileSystem.FileSystem;
26 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });46 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
27 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);47 const encoded = yield* encodeDesktopBootstrap(payload);
48 yield* fs.writeFileString(filePath, `${encoded}\n`);
28 const { fd } = yield* fs.open(filePath, { flag: "r" });49 const { fd } = yield* fs.open(filePath, { flag: "r" });
29 return fd;50 return fd;
30 });51 });
...@@ -165,13 +186,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -165,13 +186,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
165 Effect.gen(function* () {186 Effect.gen(function* () {
166 const { join } = yield* Path.Path;187 const { join } = yield* Path.Path;
167 const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");188 const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");
168 const fd = yield* openBootstrapFd({189 const fd = yield* openBootstrapFd(
169 noBrowser: true,190 makeDesktopBootstrap({
170 autoBootstrapProjectFromCwd: true,191 noBrowser: true,
171 logWebSocketEvents: true,192 tailscaleServeEnabled: false,
172 tailscaleServeEnabled: false,193 tailscaleServePort: 443,
173 tailscaleServePort: 443,194 }),
174 });195 );
175 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));196 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
176197
177 const resolved = yield* resolveServerConfig(198 const resolved = yield* resolveServerConfig(
...@@ -221,7 +242,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -221,7 +242,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
221 devUrl: new URL("http://127.0.0.1:4173"),242 devUrl: new URL("http://127.0.0.1:4173"),
222 noBrowser: false,243 noBrowser: false,
223 startupPresentation: "browser",244 startupPresentation: "browser",
224 desktopBootstrapToken: undefined,245 desktopBootstrapToken: "desktop-bootstrap-token",
225 autoBootstrapProjectFromCwd: false,246 autoBootstrapProjectFromCwd: false,
226 logWebSocketEvents: false,247 logWebSocketEvents: false,
227 tailscaleServeEnabled: false,248 tailscaleServeEnabled: false,
...@@ -234,21 +255,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -234,21 +255,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
234 Effect.gen(function* () {255 Effect.gen(function* () {
235 const { join } = yield* Path.Path;256 const { join } = yield* Path.Path;
236 const baseDir = "/tmp/t3-bootstrap-home";257 const baseDir = "/tmp/t3-bootstrap-home";
237 const fd = yield* openBootstrapFd({258 const fd = yield* openBootstrapFd(
238 mode: "desktop",259 makeDesktopBootstrap({
239 port: 4888,260 port: 4888,
240 host: "127.0.0.2",261 host: "127.0.0.2",
241 t3Home: baseDir,262 t3Home: baseDir,
242 devUrl: "http://127.0.0.1:5173",263 noBrowser: true,
243 noBrowser: true,264 desktopBootstrapToken: "desktop-token",
244 autoBootstrapProjectFromCwd: false,265 tailscaleServeEnabled: false,
245 logWebSocketEvents: true,266 tailscaleServePort: 443,
246 tailscaleServeEnabled: false,267 otlpTracesUrl: "http://localhost:4318/v1/traces",
247 tailscaleServePort: 443,268 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
248 otlpTracesUrl: "http://localhost:4318/v1/traces",269 }),
249 otlpMetricsUrl: "http://localhost:4318/v1/metrics",270 );
250 });271 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
251 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
252272
253 const resolved = yield* resolveServerConfig(273 const resolved = yield* resolveServerConfig(
254 {274 {
...@@ -292,17 +312,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -292,17 +312,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
292 baseDir,312 baseDir,
293 ...derivedPaths,313 ...derivedPaths,
294 host: "127.0.0.2",314 host: "127.0.0.2",
295 staticDir: undefined,315 staticDir: resolved.staticDir,
296 devUrl: new URL("http://127.0.0.1:5173"),316 devUrl: undefined,
297 noBrowser: true,317 noBrowser: true,
298 startupPresentation: "browser",318 startupPresentation: "browser",
299 desktopBootstrapToken: undefined,319 desktopBootstrapToken: "desktop-token",
300 autoBootstrapProjectFromCwd: false,320 autoBootstrapProjectFromCwd: false,
301 logWebSocketEvents: true,321 logWebSocketEvents: false,
302 tailscaleServeEnabled: false,322 tailscaleServeEnabled: false,
303 tailscaleServePort: 443,323 tailscaleServePort: 443,
304 });324 });
305 assert.equal(join(baseDir, "dev"), resolved.stateDir);325 assert.equal(join(baseDir, "userdata"), resolved.stateDir);
306 }),326 }),
307 );327 );
308328
...@@ -359,18 +379,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -359,18 +379,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
359 Effect.gen(function* () {379 Effect.gen(function* () {
360 const { join } = yield* Path.Path;380 const { join } = yield* Path.Path;
361 const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");381 const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
362 const fd = yield* openBootstrapFd({382 const fd = yield* openBootstrapFd(
363 mode: "desktop",383 makeDesktopBootstrap({
364 port: 4888,384 port: 4888,
365 host: "127.0.0.2",385 host: "127.0.0.2",
366 t3Home: "/tmp/t3-bootstrap-home",386 t3Home: "/tmp/t3-bootstrap-home",
367 devUrl: "http://127.0.0.1:5173",387 noBrowser: false,
368 noBrowser: false,388 desktopBootstrapToken: "desktop-token",
369 autoBootstrapProjectFromCwd: false,389 tailscaleServeEnabled: false,
370 logWebSocketEvents: false,390 tailscaleServePort: 443,
371 tailscaleServeEnabled: false,391 }),
372 tailscaleServePort: 443,392 );
373 });
374 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));393 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
375394
376 const resolved = yield* resolveServerConfig(395 const resolved = yield* resolveServerConfig(
...@@ -422,7 +441,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -422,7 +441,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
422 devUrl: new URL("http://127.0.0.1:4173"),441 devUrl: new URL("http://127.0.0.1:4173"),
423 noBrowser: true,442 noBrowser: true,
424 startupPresentation: "browser",443 startupPresentation: "browser",
425 desktopBootstrapToken: undefined,444 desktopBootstrapToken: "desktop-token",
426 autoBootstrapProjectFromCwd: true,445 autoBootstrapProjectFromCwd: true,
427 logWebSocketEvents: true,446 logWebSocketEvents: true,
428 tailscaleServeEnabled: false,447 tailscaleServeEnabled: false,
apps/server/src/cli/config.ts+3-26
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { NetService } from "@t3tools/shared/Net";1import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
3import {4import {
4 Config,5 Config,
5 Duration,6 Duration,
...@@ -26,24 +27,6 @@ import {...@@ -26,24 +27,6 @@ import {
26} from "../config.ts";27} from "../config.ts";
27import { expandHomePath, resolveBaseDir } from "../os-jank.ts";28import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
2829
29export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
30
31const BootstrapEnvelopeSchema = Schema.Struct({
32 mode: Schema.optional(RuntimeMode),
33 port: Schema.optional(PortSchema),
34 host: Schema.optional(Schema.String),
35 t3Home: Schema.optional(Schema.String),
36 devUrl: Schema.optional(Schema.URLFromString),
37 noBrowser: Schema.optional(Schema.Boolean),
38 desktopBootstrapToken: Schema.optional(Schema.String),
39 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
40 logWebSocketEvents: Schema.optional(Schema.Boolean),
41 tailscaleServeEnabled: Schema.optional(Schema.Boolean),
42 tailscaleServePort: Schema.optional(PortSchema),
43 otlpTracesUrl: Schema.optional(Schema.String),
44 otlpMetricsUrl: Schema.optional(Schema.String),
45});
46
47export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(30export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
48 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),31 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
49 Flag.optional,32 Flag.optional,
...@@ -253,7 +236,7 @@ export const resolveServerConfig = (...@@ -253,7 +236,7 @@ export const resolveServerConfig = (
253 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;236 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
254 const bootstrapEnvelope =237 const bootstrapEnvelope =
255 bootstrapFd !== undefined238 bootstrapFd !== undefined
256 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)239 ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd)
257 : Option.none();240 : Option.none();
258 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);241 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
259242
...@@ -283,11 +266,7 @@ export const resolveServerConfig = (...@@ -283,11 +266,7 @@ export const resolveServerConfig = (
283 },266 },
284 );267 );
285 const devUrl = Option.getOrElse(268 const devUrl = Option.getOrElse(
286 resolveOptionPrecedence(269 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
287 normalizedFlags.devUrl,
288 Option.fromUndefinedOr(env.devUrl),
289 Option.fromUndefinedOr(bootstrap?.devUrl),
290 ),
291 () => undefined,270 () => undefined,
292 );271 );
293 const baseDir = yield* resolveBaseDir(272 const baseDir = yield* resolveBaseDir(
...@@ -327,7 +306,6 @@ export const resolveServerConfig = (...@@ -327,7 +306,6 @@ export const resolveServerConfig = (
327 isHeadlessStartup ? Option.some(false) : Option.none(),306 isHeadlessStartup ? Option.some(false) : Option.none(),
328 normalizedFlags.autoBootstrapProjectFromCwd,307 normalizedFlags.autoBootstrapProjectFromCwd,
329 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),308 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
330 Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd),
331 ),309 ),
332 () => mode === "web",310 () => mode === "web",
333 );311 );
...@@ -335,7 +313,6 @@ export const resolveServerConfig = (...@@ -335,7 +313,6 @@ export const resolveServerConfig = (
335 resolveOptionPrecedence(313 resolveOptionPrecedence(
336 normalizedFlags.logWebSocketEvents,314 normalizedFlags.logWebSocketEvents,
337 Option.fromUndefinedOr(env.logWebSocketEvents),315 Option.fromUndefinedOr(env.logWebSocketEvents),
338 Option.fromUndefinedOr(bootstrap?.logWebSocketEvents),
339 ),316 ),
340 () => Boolean(devUrl),317 () => Boolean(devUrl),
341 );318 );
apps/server/src/http.ts+1-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import Mime from "@effect/platform-node/Mime";1import Mime from "@effect/platform-node/Mime";
2import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
2import { Data, Effect, FileSystem, Option, Path } from "effect";3import { Data, Effect, FileSystem, Option, Path } from "effect";
3import { cast } from "effect/Function";4import { cast } from "effect/Function";
4import {5import {
...@@ -18,7 +19,6 @@ import {...@@ -18,7 +19,6 @@ import {
18} from "./attachmentPaths.ts";19} from "./attachmentPaths.ts";
19import { resolveAttachmentPathById } from "./attachmentStore.ts";20import { resolveAttachmentPathById } from "./attachmentStore.ts";
20import { resolveStaticDir, ServerConfig } from "./config.ts";21import { resolveStaticDir, ServerConfig } from "./config.ts";
21import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
22import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";22import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
23import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";23import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
24import { ServerAuth } from "./auth/Services/ServerAuth.ts";24import { ServerAuth } from "./auth/Services/ServerAuth.ts";
apps/server/src/observability/Attributes.test.ts+1-36
...@@ -1,43 +1,8 @@...@@ -1,43 +1,8 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
22
3import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts";3import { normalizeModelMetricLabel } from "./Attributes.ts";
44
5describe("Attributes", () => {5describe("Attributes", () => {
6 it("normalizes circular arrays, maps, and sets without recursing forever", () => {
7 const array: Array<unknown> = ["alpha"];
8 array.push(array);
9
10 const map = new Map<string, unknown>();
11 map.set("self", map);
12
13 const set = new Set<unknown>();
14 set.add(set);
15
16 assert.deepStrictEqual(
17 compactTraceAttributes({
18 array,
19 map,
20 set,
21 }),
22 {
23 array: ["alpha", "[Circular]"],
24 map: { self: "[Circular]" },
25 set: ["[Circular]"],
26 },
27 );
28 });
29
30 it("normalizes invalid dates without throwing", () => {
31 assert.deepStrictEqual(
32 compactTraceAttributes({
33 invalidDate: new Date("not-a-real-date"),
34 }),
35 {
36 invalidDate: "Invalid Date",
37 },
38 );
39 });
40
41 it("groups GPT-family models under a shared metric label", () => {6 it("groups GPT-family models under a shared metric label", () => {
42 assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt");7 assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt");
43 assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt");8 assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt");
apps/server/src/observability/Attributes.ts-80
...@@ -2,88 +2,8 @@ import { Cause, Exit } from "effect";...@@ -2,88 +2,8 @@ import { Cause, Exit } from "effect";
22
3export type MetricAttributeValue = string;3export type MetricAttributeValue = string;
4export type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>;4export type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>;
5export type TraceAttributes = Readonly<Record<string, unknown>>;
6export type ObservabilityOutcome = "success" | "failure" | "interrupt";5export type ObservabilityOutcome = "success" | "failure" | "interrupt";
76
8function isPlainObject(value: unknown): value is Record<string, unknown> {
9 return typeof value === "object" && value !== null && !Array.isArray(value);
10}
11
12function markSeen(value: object, seen: WeakSet<object>): boolean {
13 if (seen.has(value)) {
14 return true;
15 }
16 seen.add(value);
17 return false;
18}
19
20function normalizeJsonValue(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
21 if (
22 value === null ||
23 value === undefined ||
24 typeof value === "string" ||
25 typeof value === "number" ||
26 typeof value === "boolean"
27 ) {
28 return value ?? null;
29 }
30 if (typeof value === "bigint") {
31 return value.toString();
32 }
33 if (value instanceof Date) {
34 return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
35 }
36 if (value instanceof Error) {
37 return {
38 name: value.name,
39 message: value.message,
40 ...(value.stack ? { stack: value.stack } : {}),
41 };
42 }
43 if (Array.isArray(value)) {
44 if (markSeen(value, seen)) {
45 return "[Circular]";
46 }
47 return value.map((entry) => normalizeJsonValue(entry, seen));
48 }
49 if (value instanceof Map) {
50 if (markSeen(value, seen)) {
51 return "[Circular]";
52 }
53 return Object.fromEntries(
54 Array.from(value.entries(), ([key, entryValue]) => [
55 String(key),
56 normalizeJsonValue(entryValue, seen),
57 ]),
58 );
59 }
60 if (value instanceof Set) {
61 if (markSeen(value, seen)) {
62 return "[Circular]";
63 }
64 return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen));
65 }
66 if (!isPlainObject(value)) {
67 return String(value);
68 }
69 if (markSeen(value, seen)) {
70 return "[Circular]";
71 }
72 return Object.fromEntries(
73 Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]),
74 );
75}
76
77export function compactTraceAttributes(
78 attributes: Readonly<Record<string, unknown>>,
79): TraceAttributes {
80 return Object.fromEntries(
81 Object.entries(attributes)
82 .filter(([, value]) => value !== undefined)
83 .map(([key, value]) => [key, normalizeJsonValue(value)]),
84 );
85}
86
87export function compactMetricAttributes(7export function compactMetricAttributes(
88 attributes: Readonly<Record<string, unknown>>,8 attributes: Readonly<Record<string, unknown>>,
89): MetricAttributes {9): MetricAttributes {
apps/server/src/observability/Layers/Observability.ts+1-2
...@@ -1,11 +1,10 @@...@@ -1,11 +1,10 @@
1import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
1import { Effect, Layer, References, Tracer } from "effect";2import { Effect, Layer, References, Tracer } from "effect";
2import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";3import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
34
4import { ServerConfig } from "../../config.ts";5import { ServerConfig } from "../../config.ts";
5import { ServerLoggerLive } from "../../serverLogger.ts";6import { ServerLoggerLive } from "../../serverLogger.ts";
6import { makeLocalFileTracer } from "../LocalFileTracer.ts";
7import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";7import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
8import { makeTraceSink } from "../TraceSink.ts";
98
10const otlpSerializationLayer = OtlpSerialization.layerJson;9const otlpSerializationLayer = OtlpSerialization.layerJson;
1110
apps/server/src/observability/LocalFileTracer.test.ts deleted-117
...@@ -1,117 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { assert, describe, it } from "@effect/vitest";
6import { Effect, Layer, Logger, References, Tracer } from "effect";
7
8import type { EffectTraceRecord } from "./TraceRecord.ts";
9import { makeLocalFileTracer } from "./LocalFileTracer.ts";
10
11const makeTestLayer = (tracePath: string) =>
12 Layer.mergeAll(
13 Layer.effect(
14 Tracer.Tracer,
15 makeLocalFileTracer({
16 filePath: tracePath,
17 maxBytes: 1024 * 1024,
18 maxFiles: 2,
19 batchWindowMs: 10_000,
20 }),
21 ),
22 Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }),
23 Layer.succeed(References.MinimumLogLevel, "Info"),
24 );
25
26const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> =>
27 fs
28 .readFileSync(tracePath, "utf8")
29 .trim()
30 .split("\n")
31 .filter((line) => line.length > 0)
32 .map((line) => JSON.parse(line) as EffectTraceRecord);
33
34describe("LocalFileTracer", () => {
35 it.effect("writes nested spans to disk and captures log messages as span events", () =>
36 Effect.gen(function* () {
37 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-"));
38 const tracePath = path.join(tempDir, "server.trace.ndjson");
39
40 try {
41 yield* Effect.scoped(
42 Effect.gen(function* () {
43 const program = Effect.gen(function* () {
44 yield* Effect.annotateCurrentSpan({
45 "demo.parent": true,
46 });
47 yield* Effect.logInfo("parent event");
48 yield* Effect.gen(function* () {
49 yield* Effect.annotateCurrentSpan({
50 "demo.child": true,
51 });
52 yield* Effect.logInfo("child event");
53 }).pipe(Effect.withSpan("child-span"));
54 }).pipe(Effect.withSpan("parent-span"));
55
56 yield* program.pipe(Effect.provide(makeTestLayer(tracePath)));
57 }),
58 );
59
60 const records = readTraceRecords(tracePath);
61 assert.equal(records.length, 2);
62
63 const parent = records.find((record) => record.name === "parent-span");
64 const child = records.find((record) => record.name === "child-span");
65
66 assert.notEqual(parent, undefined);
67 assert.notEqual(child, undefined);
68 if (!parent || !child) {
69 return;
70 }
71
72 assert.equal(child.parentSpanId, parent.spanId);
73 assert.equal(parent.attributes["demo.parent"], true);
74 assert.equal(child.attributes["demo.child"], true);
75 assert.equal(
76 parent.events.some((event) => event.name === "parent event"),
77 true,
78 );
79 assert.equal(
80 child.events.some((event) => event.name === "child event"),
81 true,
82 );
83 assert.equal(
84 child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"),
85 true,
86 );
87 } finally {
88 fs.rmSync(tempDir, { recursive: true, force: true });
89 }
90 }),
91 );
92
93 it.effect("serializes interrupted spans with an interrupted exit status", () =>
94 Effect.gen(function* () {
95 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-"));
96 const tracePath = path.join(tempDir, "server.trace.ndjson");
97
98 try {
99 yield* Effect.scoped(
100 Effect.exit(
101 Effect.interrupt.pipe(
102 Effect.withSpan("interrupt-span"),
103 Effect.provide(makeTestLayer(tracePath)),
104 ),
105 ),
106 );
107
108 const records = readTraceRecords(tracePath);
109 assert.equal(records.length, 1);
110 assert.equal(records[0]?.name, "interrupt-span");
111 assert.equal(records[0]?.exit._tag, "Interrupted");
112 } finally {
113 fs.rmSync(tempDir, { recursive: true, force: true });
114 }
115 }),
116 );
117});
apps/server/src/observability/LocalFileTracer.ts deleted-112
...@@ -1,112 +0,0 @@
1import type * as Exit from "effect/Exit";
2import { Effect, Option, Tracer } from "effect";
3
4import { spanToTraceRecord } from "./TraceRecord.ts";
5import type { EffectTraceRecord } from "./TraceRecord.ts";
6import { makeTraceSink, type TraceSink } from "./TraceSink.ts";
7
8export interface LocalFileTracerOptions {
9 readonly filePath: string;
10 readonly maxBytes: number;
11 readonly maxFiles: number;
12 readonly batchWindowMs: number;
13 readonly delegate?: Tracer.Tracer;
14 readonly sink?: TraceSink;
15}
16
17class LocalFileSpan implements Tracer.Span {
18 readonly _tag = "Span";
19 readonly name: string;
20 readonly spanId: string;
21 readonly traceId: string;
22 readonly parent: Option.Option<Tracer.AnySpan>;
23 readonly annotations: Tracer.Span["annotations"];
24 readonly links: Array<Tracer.SpanLink>;
25 readonly sampled: boolean;
26 readonly kind: Tracer.SpanKind;
27
28 status: Tracer.SpanStatus;
29 attributes: Map<string, unknown>;
30 events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>;
31 private readonly delegate: Tracer.Span;
32 private readonly push: (record: EffectTraceRecord) => void;
33
34 constructor(
35 options: Parameters<Tracer.Tracer["span"]>[0],
36 delegate: Tracer.Span,
37 push: (record: EffectTraceRecord) => void,
38 ) {
39 this.delegate = delegate;
40 this.push = push;
41 this.name = delegate.name;
42 this.spanId = delegate.spanId;
43 this.traceId = delegate.traceId;
44 this.parent = options.parent;
45 this.annotations = options.annotations;
46 this.links = [...options.links];
47 this.sampled = delegate.sampled;
48 this.kind = delegate.kind;
49 this.status = {
50 _tag: "Started",
51 startTime: options.startTime,
52 };
53 this.attributes = new Map();
54 this.events = [];
55 }
56
57 end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
58 this.status = {
59 _tag: "Ended",
60 startTime: this.status.startTime,
61 endTime,
62 exit,
63 };
64 this.delegate.end(endTime, exit);
65
66 if (this.sampled) {
67 this.push(spanToTraceRecord(this));
68 }
69 }
70
71 attribute(key: string, value: unknown): void {
72 this.attributes.set(key, value);
73 this.delegate.attribute(key, value);
74 }
75
76 event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
77 const nextAttributes = attributes ?? {};
78 this.events.push([name, startTime, nextAttributes]);
79 this.delegate.event(name, startTime, nextAttributes);
80 }
81
82 addLinks(links: ReadonlyArray<Tracer.SpanLink>): void {
83 this.links.push(...links);
84 this.delegate.addLinks(links);
85 }
86}
87
88export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
89 options: LocalFileTracerOptions,
90) {
91 const sink =
92 options.sink ??
93 (yield* makeTraceSink({
94 filePath: options.filePath,
95 maxBytes: options.maxBytes,
96 maxFiles: options.maxFiles,
97 batchWindowMs: options.batchWindowMs,
98 }));
99
100 const delegate =
101 options.delegate ??
102 Tracer.make({
103 span: (spanOptions) => new Tracer.NativeSpan(spanOptions),
104 });
105
106 return Tracer.make({
107 span(spanOptions) {
108 return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push);
109 },
110 ...(delegate.context ? { context: delegate.context } : {}),
111 });
112});
apps/server/src/observability/Services/BrowserTraceCollector.ts+1-2
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import type { TraceRecord } from "@t3tools/shared/observability";
1import { Context } from "effect";2import { Context } from "effect";
2import type { Effect } from "effect";3import type { Effect } from "effect";
34
4import type { TraceRecord } from "../TraceRecord.ts";
5
6export interface BrowserTraceCollectorShape {5export interface BrowserTraceCollectorShape {
7 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;6 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
8}7}
apps/server/src/observability/TraceRecord.ts deleted-287
...@@ -1,287 +0,0 @@
1import { Cause, Exit, Option, Tracer } from "effect";
2
3import { compactTraceAttributes } from "./Attributes.ts";
4import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
5
6interface TraceRecordEvent {
7 readonly name: string;
8 readonly timeUnixNano: string;
9 readonly attributes: Readonly<Record<string, unknown>>;
10}
11
12interface TraceRecordLink {
13 readonly traceId: string;
14 readonly spanId: string;
15 readonly attributes: Readonly<Record<string, unknown>>;
16}
17
18interface BaseTraceRecord {
19 readonly name: string;
20 readonly kind: string;
21 readonly traceId: string;
22 readonly spanId: string;
23 readonly parentSpanId?: string;
24 readonly sampled: boolean;
25 readonly startTimeUnixNano: string;
26 readonly endTimeUnixNano: string;
27 readonly durationMs: number;
28 readonly attributes: Readonly<Record<string, unknown>>;
29 readonly events: ReadonlyArray<TraceRecordEvent>;
30 readonly links: ReadonlyArray<TraceRecordLink>;
31}
32
33export interface EffectTraceRecord extends BaseTraceRecord {
34 readonly type: "effect-span";
35 readonly exit:
36 | {
37 readonly _tag: "Success";
38 }
39 | {
40 readonly _tag: "Interrupted";
41 readonly cause: string;
42 }
43 | {
44 readonly _tag: "Failure";
45 readonly cause: string;
46 };
47}
48
49interface OtlpTraceRecord extends BaseTraceRecord {
50 readonly type: "otlp-span";
51 readonly resourceAttributes: Readonly<Record<string, unknown>>;
52 readonly scope: Readonly<{
53 readonly name?: string;
54 readonly version?: string;
55 readonly attributes: Readonly<Record<string, unknown>>;
56 }>;
57 readonly status?:
58 | {
59 readonly code?: string;
60 readonly message?: string;
61 }
62 | undefined;
63}
64
65export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;
66
67type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number];
68type OtlpSpanEvent = OtlpSpan["events"][number];
69type OtlpSpanLink = OtlpSpan["links"][number];
70type OtlpSpanStatus = OtlpSpan["status"];
71
72interface SerializableSpan {
73 readonly name: string;
74 readonly traceId: string;
75 readonly spanId: string;
76 readonly parent: Option.Option<Tracer.AnySpan>;
77 readonly status: Tracer.SpanStatus;
78 readonly sampled: boolean;
79 readonly kind: Tracer.SpanKind;
80 readonly attributes: ReadonlyMap<string, unknown>;
81 readonly links: ReadonlyArray<Tracer.SpanLink>;
82 readonly events: ReadonlyArray<
83 readonly [name: string, startTime: bigint, attributes: Record<string, unknown>]
84 >;
85}
86
87function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] {
88 if (Exit.isSuccess(exit)) {
89 return { _tag: "Success" };
90 }
91 if (Cause.hasInterruptsOnly(exit.cause)) {
92 return {
93 _tag: "Interrupted",
94 cause: Cause.pretty(exit.cause),
95 };
96 }
97 return {
98 _tag: "Failure",
99 cause: Cause.pretty(exit.cause),
100 };
101}
102
103export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
104 const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>;
105 const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;
106
107 return {
108 type: "effect-span",
109 name: span.name,
110 traceId: span.traceId,
111 spanId: span.spanId,
112 ...(parentSpanId ? { parentSpanId } : {}),
113 sampled: span.sampled,
114 kind: span.kind,
115 startTimeUnixNano: String(status.startTime),
116 endTimeUnixNano: String(status.endTime),
117 durationMs: Number(status.endTime - status.startTime) / 1_000_000,
118 attributes: compactTraceAttributes(Object.fromEntries(span.attributes)),
119 events: span.events.map(([name, startTime, attributes]) => ({
120 name,
121 timeUnixNano: String(startTime),
122 attributes: compactTraceAttributes(attributes),
123 })),
124 links: span.links.map((link) => ({
125 traceId: link.span.traceId,
126 spanId: link.span.spanId,
127 attributes: compactTraceAttributes(link.attributes),
128 })),
129 exit: formatTraceExit(status.exit),
130 };
131}
132
133const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = {
134 1: "internal",
135 2: "server",
136 3: "client",
137 4: "producer",
138 5: "consumer",
139};
140
141export function decodeOtlpTraceRecords(
142 payload: OtlpTracer.TraceData,
143): ReadonlyArray<OtlpTraceRecord> {
144 const records: Array<OtlpTraceRecord> = [];
145
146 for (const resourceSpan of payload.resourceSpans) {
147 const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []);
148
149 for (const scopeSpan of resourceSpan.scopeSpans) {
150 for (const span of scopeSpan.spans) {
151 records.push(
152 otlpSpanToTraceRecord({
153 resourceAttributes,
154 scopeAttributes: decodeAttributes(
155 "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes)
156 ? scopeSpan.scope.attributes
157 : [],
158 ),
159 scopeName: scopeSpan.scope.name,
160 scopeVersion:
161 "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string"
162 ? scopeSpan.scope.version
163 : undefined,
164 span,
165 }),
166 );
167 }
168 }
169 }
170
171 return records;
172}
173
174function otlpSpanToTraceRecord(input: {
175 readonly resourceAttributes: Readonly<Record<string, unknown>>;
176 readonly scopeAttributes: Readonly<Record<string, unknown>>;
177 readonly scopeName: string | undefined;
178 readonly scopeVersion: string | undefined;
179 readonly span: OtlpSpan;
180}): OtlpTraceRecord {
181 return {
182 type: "otlp-span",
183 name: input.span.name,
184 traceId: input.span.traceId,
185 spanId: input.span.spanId,
186 ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}),
187 sampled: true,
188 kind: normalizeSpanKind(input.span.kind),
189 startTimeUnixNano: input.span.startTimeUnixNano,
190 endTimeUnixNano: input.span.endTimeUnixNano,
191 durationMs:
192 Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) /
193 1_000_000,
194 attributes: decodeAttributes(input.span.attributes),
195 resourceAttributes: input.resourceAttributes,
196 scope: {
197 ...(input.scopeName ? { name: input.scopeName } : {}),
198 ...(input.scopeVersion ? { version: input.scopeVersion } : {}),
199 attributes: input.scopeAttributes,
200 },
201 events: decodeEvents(input.span.events),
202 links: decodeLinks(input.span.links),
203 status: decodeStatus(input.span.status),
204 };
205}
206
207function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] {
208 const code = String(input.code);
209 const message = input.message;
210
211 return {
212 code,
213 ...(message ? { message } : {}),
214 };
215}
216
217function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> {
218 return input.map((current) => ({
219 name: current.name,
220 timeUnixNano: current.timeUnixNano,
221 attributes: decodeAttributes(current.attributes),
222 }));
223}
224
225function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> {
226 return input.flatMap((current) => {
227 const traceId = current.traceId;
228 const spanId = current.spanId;
229 return {
230 traceId,
231 spanId,
232 attributes: decodeAttributes(current.attributes),
233 };
234 });
235}
236
237function decodeAttributes(
238 input: ReadonlyArray<OtlpResource.KeyValue>,
239): Readonly<Record<string, unknown>> {
240 const entries: Record<string, unknown> = {};
241
242 for (const attribute of input) {
243 entries[attribute.key] = decodeValue(attribute.value);
244 }
245
246 return compactTraceAttributes(entries);
247}
248
249function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {
250 if (input == null) {
251 return null;
252 }
253 if ("stringValue" in input) {
254 return input.stringValue;
255 }
256 if ("boolValue" in input) {
257 return input.boolValue;
258 }
259 if ("intValue" in input) {
260 return input.intValue;
261 }
262 if ("doubleValue" in input) {
263 return input.doubleValue;
264 }
265 if ("bytesValue" in input) {
266 return input.bytesValue;
267 }
268 if (input.arrayValue) {
269 return input.arrayValue.values.map((entry) => decodeValue(entry));
270 }
271 if (input.kvlistValue) {
272 return decodeAttributes(input.kvlistValue.values);
273 }
274 return null;
275}
276
277function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] {
278 return SPAN_KIND_MAP[input] || "internal";
279}
280
281function parseBigInt(input: string): bigint {
282 try {
283 return BigInt(input);
284 } catch {
285 return 0n;
286 }
287}
apps/server/src/observability/TraceSink.test.ts deleted-152
...@@ -1,152 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { assert, describe, it } from "@effect/vitest";
6import { Effect } from "effect";
7
8import type { TraceRecord } from "./TraceRecord.ts";
9import { makeTraceSink } from "./TraceSink.ts";
10
11const makeRecord = (name: string, suffix = ""): TraceRecord => ({
12 type: "effect-span",
13 name,
14 traceId: `trace-${name}-${suffix}`,
15 spanId: `span-${name}-${suffix}`,
16 sampled: true,
17 kind: "internal",
18 startTimeUnixNano: "1",
19 endTimeUnixNano: "2",
20 durationMs: 1,
21 attributes: {
22 payload: suffix,
23 },
24 events: [],
25 links: [],
26 exit: {
27 _tag: "Success",
28 },
29});
30
31describe("TraceSink", () => {
32 it.effect("flushes buffered records on close", () =>
33 Effect.scoped(
34 Effect.gen(function* () {
35 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
36 const tracePath = path.join(tempDir, "server.trace.ndjson");
37
38 try {
39 const sink = yield* makeTraceSink({
40 filePath: tracePath,
41 maxBytes: 1024,
42 maxFiles: 2,
43 batchWindowMs: 10_000,
44 });
45
46 sink.push(makeRecord("alpha"));
47 sink.push(makeRecord("beta"));
48 yield* sink.close();
49
50 const lines = fs
51 .readFileSync(tracePath, "utf8")
52 .trim()
53 .split("\n")
54 .map((line) => JSON.parse(line) as TraceRecord);
55
56 assert.equal(lines.length, 2);
57 assert.equal(lines[0]?.name, "alpha");
58 assert.equal(lines[1]?.name, "beta");
59 } finally {
60 fs.rmSync(tempDir, { recursive: true, force: true });
61 }
62 }),
63 ),
64 );
65
66 it.effect("rotates the trace file when the configured max size is exceeded", () =>
67 Effect.scoped(
68 Effect.gen(function* () {
69 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
70 const tracePath = path.join(tempDir, "server.trace.ndjson");
71
72 try {
73 const sink = yield* makeTraceSink({
74 filePath: tracePath,
75 maxBytes: 180,
76 maxFiles: 2,
77 batchWindowMs: 10_000,
78 });
79
80 for (let index = 0; index < 8; index += 1) {
81 sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`));
82 yield* sink.flush;
83 }
84 yield* sink.close();
85
86 const matchingFiles = fs
87 .readdirSync(tempDir)
88 .filter(
89 (entry) =>
90 entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."),
91 )
92 .toSorted();
93
94 assert.equal(
95 matchingFiles.some((entry) => entry === "server.trace.ndjson.1"),
96 true,
97 );
98 assert.equal(
99 matchingFiles.some((entry) => entry === "server.trace.ndjson.3"),
100 false,
101 );
102 } finally {
103 fs.rmSync(tempDir, { recursive: true, force: true });
104 }
105 }),
106 ),
107 );
108
109 it.effect("drops only the invalid record when serialization fails", () =>
110 Effect.scoped(
111 Effect.gen(function* () {
112 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
113 const tracePath = path.join(tempDir, "server.trace.ndjson");
114
115 try {
116 const sink = yield* makeTraceSink({
117 filePath: tracePath,
118 maxBytes: 1024,
119 maxFiles: 2,
120 batchWindowMs: 10_000,
121 });
122
123 const circular: Array<unknown> = [];
124 circular.push(circular);
125
126 sink.push(makeRecord("alpha"));
127 sink.push({
128 ...makeRecord("invalid"),
129 attributes: {
130 circular,
131 },
132 } as TraceRecord);
133 sink.push(makeRecord("beta"));
134 yield* sink.close();
135
136 const lines = fs
137 .readFileSync(tracePath, "utf8")
138 .trim()
139 .split("\n")
140 .map((line) => JSON.parse(line) as TraceRecord);
141
142 assert.deepStrictEqual(
143 lines.map((line) => line.name),
144 ["alpha", "beta"],
145 );
146 } finally {
147 fs.rmSync(tempDir, { recursive: true, force: true });
148 }
149 }),
150 ),
151 );
152});
apps/server/src/observability/TraceSink.ts deleted-68
...@@ -1,68 +0,0 @@
1import { RotatingFileSink } from "@t3tools/shared/logging";
2import { Effect } from "effect";
3
4import type { TraceRecord } from "./TraceRecord.ts";
5
6const FLUSH_BUFFER_THRESHOLD = 32;
7
8export interface TraceSinkOptions {
9 readonly filePath: string;
10 readonly maxBytes: number;
11 readonly maxFiles: number;
12 readonly batchWindowMs: number;
13}
14
15export interface TraceSink {
16 readonly filePath: string;
17 push: (record: TraceRecord) => void;
18 flush: Effect.Effect<void>;
19 close: () => Effect.Effect<void>;
20}
21
22export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) {
23 const sink = new RotatingFileSink({
24 filePath: options.filePath,
25 maxBytes: options.maxBytes,
26 maxFiles: options.maxFiles,
27 });
28
29 let buffer: Array<string> = [];
30
31 const flushUnsafe = () => {
32 if (buffer.length === 0) {
33 return;
34 }
35
36 const chunk = buffer.join("");
37 buffer = [];
38
39 try {
40 sink.write(chunk);
41 } catch {
42 buffer.unshift(chunk);
43 }
44 };
45
46 const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false));
47
48 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
49 yield* Effect.forkScoped(
50 Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever),
51 );
52
53 return {
54 filePath: options.filePath,
55 push(record) {
56 try {
57 buffer.push(`${JSON.stringify(record)}\n`);
58 if (buffer.length >= FLUSH_BUFFER_THRESHOLD) {
59 flushUnsafe();
60 }
61 } catch {
62 return;
63 }
64 },
65 flush,
66 close: () => flush,
67 } satisfies TraceSink;
68});
apps/server/src/provider/Layers/CursorAdapter.test.ts+1
...@@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) {...@@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) {
113// tests assumed.113// tests assumed.
114const makeResolveCursorSettings = Effect.gen(function* () {114const makeResolveCursorSettings = Effect.gen(function* () {
115 const serverSettings = yield* ServerSettingsService;115 const serverSettings = yield* ServerSettingsService;
116 // @effect-diagnostics effect/returnEffectInGen:off
116 return serverSettings.getSettings.pipe(117 return serverSettings.getSettings.pipe(
117 Effect.map((snapshot) => snapshot.providers.cursor),118 Effect.map((snapshot) => snapshot.providers.cursor),
118 Effect.orDie,119 Effect.orDie,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+9-25
...@@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({...@@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({
193193
194const OpenCodeAdapterTestLayer = Layer.effect(194const OpenCodeAdapterTestLayer = Layer.effect(
195 OpenCodeAdapter,195 OpenCodeAdapter,
196 Effect.gen(function* () {196 makeOpenCodeAdapter(openCodeAdapterTestSettings),
197 return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings);
198 }),
199).pipe(197).pipe(
200 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),198 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
201 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),199 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
...@@ -390,14 +388,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -390,14 +388,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
390 );388 );
391389
392 it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {390 it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
393 const customInstanceId = ProviderInstanceId.make("opencode_zen");391 const instanceId = ProviderInstanceId.make("opencode_zen");
394 const adapterLayer = Layer.effect(392 const adapterLayer = Layer.effect(
395 OpenCodeAdapter,393 OpenCodeAdapter,
396 Effect.gen(function* () {394 makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
397 return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
398 instanceId: customInstanceId,
399 });
400 }),
401 ).pipe(395 ).pipe(
402 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),396 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
403 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),397 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
...@@ -441,14 +435,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -441,14 +435,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
441 });435 });
442436
443 it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => {437 it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => {
444 const customInstanceId = ProviderInstanceId.make("opencode_zen");438 const instanceId = ProviderInstanceId.make("opencode_zen");
445 const adapterLayer = Layer.effect(439 const adapterLayer = Layer.effect(
446 OpenCodeAdapter,440 OpenCodeAdapter,
447 Effect.gen(function* () {441 makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
448 return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
449 instanceId: customInstanceId,
450 });
451 }),
452 ).pipe(442 ).pipe(
453 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),443 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
454 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),444 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
...@@ -487,14 +477,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -487,14 +477,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
487 });477 });
488478
489 it.effect("rejects sendTurn model selections for another instance id", () => {479 it.effect("rejects sendTurn model selections for another instance id", () => {
490 const customInstanceId = ProviderInstanceId.make("opencode_zen");480 const instanceId = ProviderInstanceId.make("opencode_zen");
491 const adapterLayer = Layer.effect(481 const adapterLayer = Layer.effect(
492 OpenCodeAdapter,482 OpenCodeAdapter,
493 Effect.gen(function* () {483 makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
494 return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
495 instanceId: customInstanceId,
496 });
497 }),
498 ).pipe(484 ).pipe(
499 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),485 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
500 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),486 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
...@@ -634,10 +620,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -634,10 +620,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
634620
635 const adapterLayer = Layer.effect(621 const adapterLayer = Layer.effect(
636 OpenCodeAdapter,622 OpenCodeAdapter,
637 Effect.gen(function* () {623 makeOpenCodeAdapter(openCodeAdapterTestSettings, {
638 return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {624 nativeEventLogger,
639 nativeEventLogger,
640 });
641 }),625 }),
642 ).pipe(626 ).pipe(
643 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),627 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
apps/server/src/sourceControl/SourceControlRepositoryService.ts+15-21
...@@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()...@@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()
153 function* (destinationPath: string) {153 function* (destinationPath: string) {
154 const trimmed = destinationPath.trim();154 const trimmed = destinationPath.trim();
155 if (trimmed.length === 0) {155 if (trimmed.length === 0) {
156 return yield* Effect.fail(156 return yield* repositoryError({
157 repositoryError({157 operation: "cloneRepository",
158 operation: "cloneRepository",158 provider: "unknown",
159 provider: "unknown",159 detail: "Choose a destination path before cloning.",
160 detail: "Choose a destination path before cloning.",160 });
161 }),
162 );
163 }161 }
164162
165 return path.resolve(expandHomePath(trimmed, path));163 return path.resolve(expandHomePath(trimmed, path));
...@@ -183,13 +181,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()...@@ -183,13 +181,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()
183 ),181 ),
184 );182 );
185 if (entries.length > 0) {183 if (entries.length > 0) {
186 return yield* Effect.fail(184 return yield* repositoryError({
187 repositoryError({185 operation: "cloneRepository",
188 operation: "cloneRepository",186 provider: "unknown",
189 provider: "unknown",187 detail: "Destination path already exists and is not empty.",
190 detail: "Destination path already exists and is not empty.",188 });
191 }),
192 );
193 }189 }
194 } else {190 } else {
195 yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });191 yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });
...@@ -222,13 +218,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()...@@ -222,13 +218,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* ()
222 }218 }
223219
224 if (!remoteUrl) {220 if (!remoteUrl) {
225 return yield* Effect.fail(221 return yield* repositoryError({
226 repositoryError({222 operation: "cloneRepository",
227 operation: "cloneRepository",223 provider,
228 provider,224 detail: "Enter a repository path or clone URL before cloning.",
229 detail: "Enter a repository path or clone URL before cloning.",225 });
230 }),
231 );
232 }226 }
233227
234 yield* git.execute({228 yield* git.execute({
apps/server/src/vcs/GitVcsDriverCore.ts+2-2
...@@ -20,7 +20,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -20,7 +20,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020
21import { GitCommandError, type VcsRef } from "@t3tools/contracts";21import { GitCommandError, type VcsRef } from "@t3tools/contracts";
22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
23import { compactTraceAttributes } from "../observability/Attributes.ts";23import { compactTraceAttributes } from "@t3tools/shared/observability";
24import { decodeJsonResult } from "@t3tools/shared/schemaJson";
24import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";25import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
25import * as GitVcsDriver from "./GitVcsDriver.ts";26import * as GitVcsDriver from "./GitVcsDriver.ts";
26import {27import {
...@@ -29,7 +30,6 @@ import {...@@ -29,7 +30,6 @@ import {
29 parseRemoteRefWithRemoteNames,30 parseRemoteRefWithRemoteNames,
30} from "../git/remoteRefs.ts";31} from "../git/remoteRefs.ts";
31import { ServerConfig } from "../config.ts";32import { ServerConfig } from "../config.ts";
32import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3333
34const DEFAULT_TIMEOUT_MS = 30_000;34const DEFAULT_TIMEOUT_MS = 30_000;
35const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;35const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
apps/web/tsconfig.json+1-1
...@@ -18,7 +18,7 @@...@@ -18,7 +18,7 @@
18 "namespaceImportPackages": ["@effect/platform-node"],18 "namespaceImportPackages": ["@effect/platform-node"],
19 "diagnosticSeverity": {19 "diagnosticSeverity": {
20 "importFromBarrel": "error",20 "importFromBarrel": "error",
21 "anyUnknownInErrorContext": "warning",21 "anyUnknownInErrorContext": "error",
22 "instanceOfSchema": "warning",22 "instanceOfSchema": "warning",
23 "deterministicKeys": "warning"23 "deterministicKeys": "warning"
24 }24 }
bun.lock+4-2
...@@ -20,10 +20,12 @@...@@ -20,10 +20,12 @@
20 "dependencies": {20 "dependencies": {
21 "@effect/platform-node": "catalog:",21 "@effect/platform-node": "catalog:",
22 "effect": "catalog:",22 "effect": "catalog:",
23 "electron": "40.9.3",23 "electron": "41.5.0",
24 "electron-updater": "^6.6.2",24 "electron-updater": "^6.6.2",
25 },25 },
26 "devDependencies": {26 "devDependencies": {
27 "@effect/language-service": "catalog:",
28 "@effect/vitest": "catalog:",
27 "@t3tools/client-runtime": "workspace:*",29 "@t3tools/client-runtime": "workspace:*",
28 "@t3tools/contracts": "workspace:*",30 "@t3tools/contracts": "workspace:*",
29 "@t3tools/shared": "workspace:*",31 "@t3tools/shared": "workspace:*",
...@@ -1150,7 +1152,7 @@...@@ -1150,7 +1152,7 @@
11501152
1151 "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"],1153 "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"],
11521154
1153 "electron": ["electron@40.9.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg=="],1155 "electron": ["electron@41.5.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg=="],
11541156
1155 "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],1157 "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
11561158
packages/client-runtime/tsconfig.json+14
...@@ -1,4 +1,18 @@...@@ -1,4 +1,18 @@
1{1{
2 "extends": "../../tsconfig.base.json",2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "plugins": [
5 {
6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node", "effect"],
8 "diagnosticSeverity": {
9 "importFromBarrel": "error",
10 "anyUnknownInErrorContext": "error",
11 "instanceOfSchema": "warning",
12 "deterministicKeys": "warning"
13 }
14 }
15 ]
16 },
3 "include": ["src"]17 "include": ["src"]
4}18}
packages/contracts/src/auth.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
22
3import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
44
packages/contracts/src/baseSchemas.ts+2-1
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
22
3export const TrimmedString = Schema.Trim;3export const TrimmedString = Schema.Trim;
4export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());4export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());
55
6export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));6export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
7export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));7export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
8export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
89
9export const IsoDateTime = Schema.String;10export const IsoDateTime = Schema.String;
10export type IsoDateTime = typeof IsoDateTime.Type;11export type IsoDateTime = typeof IsoDateTime.Type;
packages/contracts/src/desktopBootstrap.ts created+18
...@@ -0,0 +1,18 @@
1import * as Schema from "effect/Schema";
2
3import { PortSchema } from "./baseSchemas.ts";
4
5export const DesktopBackendBootstrap = Schema.Struct({
6 mode: Schema.Literal("desktop"),
7 noBrowser: Schema.Boolean,
8 port: PortSchema,
9 t3Home: Schema.String,
10 host: Schema.String,
11 desktopBootstrapToken: Schema.String,
12 tailscaleServeEnabled: Schema.Boolean,
13 tailscaleServePort: PortSchema,
14 otlpTracesUrl: Schema.optional(Schema.String),
15 otlpMetricsUrl: Schema.optional(Schema.String),
16});
17
18export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;
packages/contracts/src/editor.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33
4export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]);4export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]);
packages/contracts/src/environment.ts+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Effect, Schema } from "effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
23
3import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";4import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
45
packages/contracts/src/filesystem.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33
4const FILESYSTEM_PATH_MAX_LENGTH = 512;4const FILESYSTEM_PATH_MAX_LENGTH = 512;
packages/contracts/src/git.test.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import * as Schema from "effect/Schema";
33
4import {4import {
5 VcsCreateWorktreeInput,5 VcsCreateWorktreeInput,
packages/contracts/src/git.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";2import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts";3import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts";
4import { VcsDriverKind } from "./vcs.ts";4import { VcsDriverKind } from "./vcs.ts";
packages/contracts/src/index.ts+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1export * from "./baseSchemas.ts";1export * from "./baseSchemas.ts";
2export * from "./auth.ts";2export * from "./auth.ts";
3export * from "./environment.ts";3export * from "./environment.ts";
4export * from "./desktopBootstrap.ts";
4export * from "./remoteAccess.ts";5export * from "./remoteAccess.ts";
5export * from "./ipc.ts";6export * from "./ipc.ts";
6export * from "./terminal.ts";7export * from "./terminal.ts";
packages/contracts/src/ipc.ts+187-23
...@@ -48,6 +48,7 @@ import type {...@@ -48,6 +48,7 @@ import type {
48 TerminalWriteInput,48 TerminalWriteInput,
49} from "./terminal.ts";49} from "./terminal.ts";
50import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";50import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";
51import * as Schema from "effect/Schema";
51import type {52import type {
52 ClientOrchestrationCommand,53 ClientOrchestrationCommand,
53 OrchestrationGetFullThreadDiffInput,54 OrchestrationGetFullThreadDiffInput,
...@@ -58,15 +59,11 @@ import type {...@@ -58,15 +59,11 @@ import type {
58 OrchestrationSubscribeThreadInput,59 OrchestrationSubscribeThreadInput,
59 OrchestrationThreadStreamItem,60 OrchestrationThreadStreamItem,
60} from "./orchestration.ts";61} from "./orchestration.ts";
61import type { EnvironmentId } from "./baseSchemas.ts";62import { EnvironmentId } from "./baseSchemas.ts";
62import type {63import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts";
63 AuthBearerBootstrapResult,64import { AdvertisedEndpoint } from "./remoteAccess.ts";
64 AuthSessionState,
65 AuthWebSocketTokenResult,
66} from "./auth.ts";
67import type { AdvertisedEndpoint } from "./remoteAccess.ts";
68import { EditorId } from "./editor.ts";65import { EditorId } from "./editor.ts";
69import type { ExecutionEnvironmentDescriptor } from "./environment.ts";66import { ExecutionEnvironmentDescriptor } from "./environment.ts";
70import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts";67import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts";
71import type {68import type {
72 SourceControlCloneRepositoryInput,69 SourceControlCloneRepositoryInput,
...@@ -86,6 +83,26 @@ export interface ContextMenuItem<T extends string = string> {...@@ -86,6 +83,26 @@ export interface ContextMenuItem<T extends string = string> {
86 children?: readonly ContextMenuItem<T>[];83 children?: readonly ContextMenuItem<T>[];
87}84}
8885
86export interface ContextMenuItemSchemaType {
87 readonly id: string;
88 readonly label: string;
89 readonly destructive?: boolean;
90 readonly disabled?: boolean;
91 readonly children?: readonly ContextMenuItemSchemaType[];
92}
93
94export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Schema.Struct({
95 id: Schema.String,
96 label: Schema.String,
97 destructive: Schema.optionalKey(Schema.Boolean),
98 disabled: Schema.optionalKey(Schema.Boolean),
99 children: Schema.optionalKey(
100 Schema.Array(
101 Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema),
102 ),
103 ),
104});
105
89export type DesktopUpdateStatus =106export type DesktopUpdateStatus =
90 | "disabled"107 | "disabled"
91 | "idle"108 | "idle"
...@@ -101,18 +118,45 @@ export type DesktopTheme = "light" | "dark" | "system";...@@ -101,18 +118,45 @@ export type DesktopTheme = "light" | "dark" | "system";
101export type DesktopUpdateChannel = "latest" | "nightly";118export type DesktopUpdateChannel = "latest" | "nightly";
102export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly";119export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly";
103120
121export const DesktopUpdateStatusSchema = Schema.Literals([
122 "disabled",
123 "idle",
124 "checking",
125 "up-to-date",
126 "available",
127 "downloading",
128 "downloaded",
129 "error",
130]);
131export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]);
132export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]);
133export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]);
134export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]);
135
104export interface DesktopAppBranding {136export interface DesktopAppBranding {
105 baseName: string;137 baseName: string;
106 stageLabel: DesktopAppStageLabel;138 stageLabel: DesktopAppStageLabel;
107 displayName: string;139 displayName: string;
108}140}
109141
142export const DesktopAppBrandingSchema = Schema.Struct({
143 baseName: Schema.String,
144 stageLabel: DesktopAppStageLabelSchema,
145 displayName: Schema.String,
146});
147
110export interface DesktopRuntimeInfo {148export interface DesktopRuntimeInfo {
111 hostArch: DesktopRuntimeArch;149 hostArch: DesktopRuntimeArch;
112 appArch: DesktopRuntimeArch;150 appArch: DesktopRuntimeArch;
113 runningUnderArm64Translation: boolean;151 runningUnderArm64Translation: boolean;
114}152}
115153
154export const DesktopRuntimeInfoSchema = Schema.Struct({
155 hostArch: DesktopRuntimeArchSchema,
156 appArch: DesktopRuntimeArchSchema,
157 runningUnderArm64Translation: Schema.Boolean,
158});
159
116export interface DesktopUpdateState {160export interface DesktopUpdateState {
117 enabled: boolean;161 enabled: boolean;
118 status: DesktopUpdateStatus;162 status: DesktopUpdateStatus;
...@@ -130,17 +174,45 @@ export interface DesktopUpdateState {...@@ -130,17 +174,45 @@ export interface DesktopUpdateState {
130 canRetry: boolean;174 canRetry: boolean;
131}175}
132176
177export const DesktopUpdateStateSchema = Schema.Struct({
178 enabled: Schema.Boolean,
179 status: DesktopUpdateStatusSchema,
180 channel: DesktopUpdateChannelSchema,
181 currentVersion: Schema.String,
182 hostArch: DesktopRuntimeArchSchema,
183 appArch: DesktopRuntimeArchSchema,
184 runningUnderArm64Translation: Schema.Boolean,
185 availableVersion: Schema.NullOr(Schema.String),
186 downloadedVersion: Schema.NullOr(Schema.String),
187 downloadPercent: Schema.NullOr(Schema.Number),
188 checkedAt: Schema.NullOr(Schema.String),
189 message: Schema.NullOr(Schema.String),
190 errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])),
191 canRetry: Schema.Boolean,
192});
193
133export interface DesktopUpdateActionResult {194export interface DesktopUpdateActionResult {
134 accepted: boolean;195 accepted: boolean;
135 completed: boolean;196 completed: boolean;
136 state: DesktopUpdateState;197 state: DesktopUpdateState;
137}198}
138199
200export const DesktopUpdateActionResultSchema = Schema.Struct({
201 accepted: Schema.Boolean,
202 completed: Schema.Boolean,
203 state: DesktopUpdateStateSchema,
204});
205
139export interface DesktopUpdateCheckResult {206export interface DesktopUpdateCheckResult {
140 checked: boolean;207 checked: boolean;
141 state: DesktopUpdateState;208 state: DesktopUpdateState;
142}209}
143210
211export const DesktopUpdateCheckResultSchema = Schema.Struct({
212 checked: Schema.Boolean,
213 state: DesktopUpdateStateSchema,
214});
215
144export interface DesktopEnvironmentBootstrap {216export interface DesktopEnvironmentBootstrap {
145 label: string;217 label: string;
146 httpBaseUrl: string | null;218 httpBaseUrl: string | null;
...@@ -148,19 +220,36 @@ export interface DesktopEnvironmentBootstrap {...@@ -148,19 +220,36 @@ export interface DesktopEnvironmentBootstrap {
148 bootstrapToken?: string;220 bootstrapToken?: string;
149}221}
150222
151export interface DesktopSshEnvironmentTarget {223export const DesktopEnvironmentBootstrapSchema = Schema.Struct({
152 alias: string;224 label: Schema.String,
153 hostname: string;225 httpBaseUrl: Schema.NullOr(Schema.String),
154 username: string | null;226 wsBaseUrl: Schema.NullOr(Schema.String),
155 port: number | null;227 bootstrapToken: Schema.optionalKey(Schema.String),
156}228});
229
230export const DesktopSshEnvironmentTargetSchema = Schema.Struct({
231 alias: Schema.String,
232 hostname: Schema.String,
233 username: Schema.NullOr(Schema.String),
234 port: Schema.NullOr(Schema.Number),
235});
236export type DesktopSshEnvironmentTarget = typeof DesktopSshEnvironmentTargetSchema.Type;
157237
158export type DesktopSshHostSource = "ssh-config" | "known-hosts";238export type DesktopSshHostSource = "ssh-config" | "known-hosts";
239export const DesktopSshHostSourceSchema = Schema.Literals(["ssh-config", "known-hosts"]);
159240
160export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget {241export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget {
161 source: DesktopSshHostSource;242 source: DesktopSshHostSource;
162}243}
163244
245export const DesktopDiscoveredSshHostSchema = Schema.Struct({
246 alias: Schema.String,
247 hostname: Schema.String,
248 username: Schema.NullOr(Schema.String),
249 port: Schema.NullOr(Schema.Number),
250 source: DesktopSshHostSourceSchema,
251});
252
164export interface DesktopSshEnvironmentBootstrap {253export interface DesktopSshEnvironmentBootstrap {
165 target: DesktopSshEnvironmentTarget;254 target: DesktopSshEnvironmentTarget;
166 httpBaseUrl: string;255 httpBaseUrl: string;
...@@ -170,6 +259,15 @@ export interface DesktopSshEnvironmentBootstrap {...@@ -170,6 +259,15 @@ export interface DesktopSshEnvironmentBootstrap {
170 remoteServerKind?: "external" | "managed";259 remoteServerKind?: "external" | "managed";
171}260}
172261
262export const DesktopSshEnvironmentBootstrapSchema = Schema.Struct({
263 target: DesktopSshEnvironmentTargetSchema,
264 httpBaseUrl: Schema.String,
265 wsBaseUrl: Schema.String,
266 pairingToken: Schema.NullOr(Schema.String),
267 remotePort: Schema.optionalKey(Schema.Number),
268 remoteServerKind: Schema.optionalKey(Schema.Literals(["external", "managed"])),
269});
270
173export interface DesktopSshPasswordPromptRequest {271export interface DesktopSshPasswordPromptRequest {
174 requestId: string;272 requestId: string;
175 destination: string;273 destination: string;
...@@ -178,18 +276,72 @@ export interface DesktopSshPasswordPromptRequest {...@@ -178,18 +276,72 @@ export interface DesktopSshPasswordPromptRequest {
178 expiresAt: string;276 expiresAt: string;
179}277}
180278
181export interface PersistedSavedEnvironmentRecord {279export const DesktopSshPasswordPromptRequestSchema = Schema.Struct({
182 environmentId: EnvironmentId;280 requestId: Schema.String,
183 label: string;281 destination: Schema.String,
184 wsBaseUrl: string;282 username: Schema.NullOr(Schema.String),
185 httpBaseUrl: string;283 prompt: Schema.String,
186 createdAt: string;284 expiresAt: Schema.String,
187 lastConnectedAt: string | null;285});
188 desktopSsh?: DesktopSshEnvironmentTarget;286
189}287export const DesktopSshPasswordPromptCancelledType = "ssh-password-prompt-cancelled" as const;
288
289export const DesktopSshPasswordPromptCancelledResultSchema = Schema.Struct({
290 type: Schema.Literal(DesktopSshPasswordPromptCancelledType),
291 message: Schema.String,
292});
293
294export const DesktopSshEnvironmentEnsureOptionsSchema = Schema.Struct({
295 issuePairingToken: Schema.optionalKey(Schema.Boolean),
296});
297
298export const DesktopSshEnvironmentEnsureInputSchema = Schema.Struct({
299 target: DesktopSshEnvironmentTargetSchema,
300 options: Schema.optionalKey(DesktopSshEnvironmentEnsureOptionsSchema),
301});
302
303export const DesktopSshEnvironmentEnsureResultSchema = Schema.Union([
304 DesktopSshEnvironmentBootstrapSchema,
305 DesktopSshPasswordPromptCancelledResultSchema,
306]);
307
308export const DesktopSshHttpBaseUrlInputSchema = Schema.Struct({
309 httpBaseUrl: Schema.String,
310});
311
312export const DesktopSshBearerRequestInputSchema = Schema.Struct({
313 httpBaseUrl: Schema.String,
314 bearerToken: Schema.String,
315});
316
317export const DesktopSshBearerBootstrapInputSchema = Schema.Struct({
318 httpBaseUrl: Schema.String,
319 credential: Schema.String,
320});
321
322export const DesktopSshPasswordPromptResolutionInputSchema = Schema.Struct({
323 requestId: Schema.String,
324 password: Schema.NullOr(Schema.String),
325});
326
327export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({
328 environmentId: EnvironmentId,
329 label: Schema.String,
330 wsBaseUrl: Schema.String,
331 httpBaseUrl: Schema.String,
332 createdAt: Schema.String,
333 lastConnectedAt: Schema.NullOr(Schema.String),
334 desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema),
335});
336export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type;
190337
191export type DesktopServerExposureMode = "local-only" | "network-accessible";338export type DesktopServerExposureMode = "local-only" | "network-accessible";
192339
340export const DesktopServerExposureModeSchema = Schema.Literals([
341 "local-only",
342 "network-accessible",
343]);
344
193export interface DesktopServerExposureState {345export interface DesktopServerExposureState {
194 mode: DesktopServerExposureMode;346 mode: DesktopServerExposureMode;
195 endpointUrl: string | null;347 endpointUrl: string | null;
...@@ -198,10 +350,22 @@ export interface DesktopServerExposureState {...@@ -198,10 +350,22 @@ export interface DesktopServerExposureState {
198 tailscaleServePort: number;350 tailscaleServePort: number;
199}351}
200352
353export const DesktopServerExposureStateSchema = Schema.Struct({
354 mode: DesktopServerExposureModeSchema,
355 endpointUrl: Schema.NullOr(Schema.String),
356 advertisedHost: Schema.NullOr(Schema.String),
357 tailscaleServeEnabled: Schema.Boolean,
358 tailscaleServePort: Schema.Number,
359});
360
201export interface PickFolderOptions {361export interface PickFolderOptions {
202 initialPath?: string | null;362 initialPath?: string | null;
203}363}
204364
365export const PickFolderOptionsSchema = Schema.Struct({
366 initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)),
367});
368
205export interface DesktopBridge {369export interface DesktopBridge {
206 getAppBranding: () => DesktopAppBranding | null;370 getAppBranding: () => DesktopAppBranding | null;
207 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;371 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
packages/contracts/src/keybindings.test.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { Schema } from "effect";
2import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
3import { Effect } from "effect";2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";
44
5import {5import {
6 KeybindingsConfig,6 KeybindingsConfig,
packages/contracts/src/keybindings.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { TrimmedString } from "./baseSchemas.ts";2import { TrimmedString } from "./baseSchemas.ts";
33
4export const MAX_KEYBINDING_VALUE_LENGTH = 64;4export const MAX_KEYBINDING_VALUE_LENGTH = 64;
packages/contracts/src/model.ts+3-1
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { Effect, Schema, SchemaTransformation } from "effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";4import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ProviderDriverKind } from "./providerInstance.ts";5import { ProviderDriverKind } from "./providerInstance.ts";
46
packages/contracts/src/orchestration.test.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import assert from "node:assert/strict";1import assert from "node:assert/strict";
2import { it } from "@effect/vitest";2import { it } from "@effect/vitest";
3import { Effect, Schema } from "effect";3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
45
5import {6import {
6 DEFAULT_PROVIDER_INTERACTION_MODE,7 DEFAULT_PROVIDER_INTERACTION_MODE,
packages/contracts/src/orchestration.ts+6-1
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect";1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import * as SchemaIssue from "effect/SchemaIssue";
5import * as SchemaTransformation from "effect/SchemaTransformation";
6import * as Struct from "effect/Struct";
2import { ProviderOptionSelections } from "./model.ts";7import { ProviderOptionSelections } from "./model.ts";
3import { RepositoryIdentity } from "./environment.ts";8import { RepositoryIdentity } from "./environment.ts";
4import {9import {
packages/contracts/src/project.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
33
4const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;4const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
packages/contracts/src/provider.test.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import * as Schema from "effect/Schema";
33
4import {4import {
5 ProviderEvent,5 ProviderEvent,
packages/contracts/src/provider.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import {3import {
4 ApprovalRequestId,4 ApprovalRequestId,
packages/contracts/src/providerInstance.test.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import * as Schema from "effect/Schema";
33
4import {4import {
5 ProviderDriverKind,5 ProviderDriverKind,
packages/contracts/src/providerInstance.ts+2-1
...@@ -33,7 +33,8 @@...@@ -33,7 +33,8 @@
33 *33 *
34 * @module providerInstance34 * @module providerInstance
35 */35 */
36import { Effect, Schema } from "effect";36import * as Effect from "effect/Effect";
37import * as Schema from "effect/Schema";
37import { TrimmedNonEmptyString } from "./baseSchemas.ts";38import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3839
39const PROVIDER_SLUG_MAX_CHARS = 64;40const PROVIDER_SLUG_MAX_CHARS = 64;
packages/contracts/src/providerRuntime.test.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import * as Schema from "effect/Schema";
33
4import { ProviderRuntimeEvent } from "./providerRuntime.ts";4import { ProviderRuntimeEvent } from "./providerRuntime.ts";
55
packages/contracts/src/providerRuntime.ts+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Effect, Schema } from "effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
2import {3import {
3 EventId,4 EventId,
4 IsoDateTime,5 IsoDateTime,
packages/contracts/src/remoteAccess.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
22
3import { TrimmedNonEmptyString } from "./baseSchemas.ts";3import { TrimmedNonEmptyString } from "./baseSchemas.ts";
44
packages/contracts/src/rpc.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import * as Rpc from "effect/unstable/rpc/Rpc";2import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
packages/contracts/src/server.test.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { ServerProvider } from "./server.ts";4import { ServerProvider } from "./server.ts";
packages/contracts/src/server.ts+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Effect, Schema } from "effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
2import { ExecutionEnvironmentDescriptor } from "./environment.ts";3import { ExecutionEnvironmentDescriptor } from "./environment.ts";
3import { ServerAuthDescriptor } from "./auth.ts";4import { ServerAuthDescriptor } from "./auth.ts";
4import {5import {
packages/contracts/src/settings.test.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import * as Schema from "effect/Schema";
33
4import { ProviderInstanceId } from "./providerInstance.ts";4import { ProviderInstanceId } from "./providerInstance.ts";
5import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";5import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";
packages/contracts/src/settings.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Effect } from "effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";3import * as SchemaTransformation from "effect/SchemaTransformation";
4import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";4import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
packages/contracts/src/sourceControl.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { VcsDriverKind } from "./vcs.ts";3import { VcsDriverKind } from "./vcs.ts";
44
packages/contracts/src/terminal.test.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import {
packages/contracts/src/terminal.ts+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Effect, Schema } from "effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";3import { TrimmedNonEmptyString } from "./baseSchemas.ts";
34
4export const DEFAULT_TERMINAL_ID = "default";5export const DEFAULT_TERMINAL_ID = "default";
packages/contracts/src/vcs.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33
4export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]);4export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]);
packages/contracts/tsconfig.json+2-2
...@@ -4,10 +4,10 @@...@@ -4,10 +4,10 @@
4 "plugins": [4 "plugins": [
5 {5 {
6 "name": "@effect/language-service",6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node"],7 "namespaceImportPackages": ["@effect/platform-node", "effect"],
8 "diagnosticSeverity": {8 "diagnosticSeverity": {
9 "importFromBarrel": "error",9 "importFromBarrel": "error",
10 "anyUnknownInErrorContext": "warning",10 "anyUnknownInErrorContext": "error",
11 "instanceOfSchema": "warning",11 "instanceOfSchema": "warning",
12 "deterministicKeys": "warning"12 "deterministicKeys": "warning"
13 }13 }
packages/effect-acp/scripts/generate.ts+6-1
...@@ -3,7 +3,12 @@...@@ -3,7 +3,12 @@
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
7import { Command, Flag } from "effect/unstable/cli";12import { Command, Flag } from "effect/unstable/cli";
8import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";13import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";
9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
packages/effect-acp/tsconfig.json+2-2
...@@ -4,10 +4,10 @@...@@ -4,10 +4,10 @@
4 "plugins": [4 "plugins": [
5 {5 {
6 "name": "@effect/language-service",6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node"],7 "namespaceImportPackages": ["@effect/platform-node", "effect"],
8 "diagnosticSeverity": {8 "diagnosticSeverity": {
9 "importFromBarrel": "error",9 "importFromBarrel": "error",
10 "anyUnknownInErrorContext": "warning",10 "anyUnknownInErrorContext": "error",
11 "instanceOfSchema": "warning",11 "instanceOfSchema": "warning",
12 "deterministicKeys": "warning"12 "deterministicKeys": "warning"
13 }13 }
packages/effect-codex-app-server/scripts/generate.ts+6-1
...@@ -3,7 +3,12 @@...@@ -3,7 +3,12 @@
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
7import {12import {
8 FetchHttpClient,13 FetchHttpClient,
9 HttpClient,14 HttpClient,
packages/effect-codex-app-server/tsconfig.json+2-2
...@@ -4,10 +4,10 @@...@@ -4,10 +4,10 @@
4 "plugins": [4 "plugins": [
5 {5 {
6 "name": "@effect/language-service",6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node"],7 "namespaceImportPackages": ["@effect/platform-node", "effect"],
8 "diagnosticSeverity": {8 "diagnosticSeverity": {
9 "importFromBarrel": "error",9 "importFromBarrel": "error",
10 "anyUnknownInErrorContext": "warning",10 "anyUnknownInErrorContext": "error",
11 "instanceOfSchema": "warning",11 "instanceOfSchema": "warning",
12 "deterministicKeys": "warning"12 "deterministicKeys": "warning"
13 }13 }
packages/shared/package.json+4
...@@ -20,6 +20,10 @@...@@ -20,6 +20,10 @@
20 "types": "./src/logging.ts",20 "types": "./src/logging.ts",
21 "import": "./src/logging.ts"21 "import": "./src/logging.ts"
22 },22 },
23 "./observability": {
24 "types": "./src/observability.ts",
25 "import": "./src/observability.ts"
26 },
23 "./shell": {27 "./shell": {
24 "types": "./src/shell.ts",28 "types": "./src/shell.ts",
25 "import": "./src/shell.ts"29 "import": "./src/shell.ts"
packages/shared/src/DrainableWorker.test.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { it } from "@effect/vitest";1import { it } from "@effect/vitest";
2import { describe, expect } from "vitest";2import { describe, expect } from "vitest";
3import { Deferred, Effect } from "effect";3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
45
5import { makeDrainableWorker } from "./DrainableWorker.ts";6import { makeDrainableWorker } from "./DrainableWorker.ts";
67
packages/shared/src/DrainableWorker.ts+4-2
...@@ -8,8 +8,10 @@...@@ -8,8 +8,10 @@
8 *8 *
9 * @module DrainableWorker9 * @module DrainableWorker
10 */10 */
11import type { Scope } from "effect";11import * as Scope from "effect/Scope";
12import { Effect, TxQueue, TxRef } from "effect";12import * as Effect from "effect/Effect";
13import * as TxQueue from "effect/TxQueue";
14import * as TxRef from "effect/TxRef";
1315
14export interface DrainableWorker<A> {16export interface DrainableWorker<A> {
15 /**17 /**
packages/shared/src/KeyedCoalescingWorker.test.ts+3-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { it } from "@effect/vitest";1import { it } from "@effect/vitest";
2import { describe, expect } from "vitest";2import { describe, expect } from "vitest";
3import { Deferred, Effect } from "effect";3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
45
5import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts";6import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts";
67
...@@ -73,7 +74,7 @@ describe("makeKeyedCoalescingWorker", () => {...@@ -73,7 +74,7 @@ describe("makeKeyedCoalescingWorker", () => {
73 if (value === "first") {74 if (value === "first") {
74 yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie);75 yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie);
75 yield* Deferred.await(releaseFailure);76 yield* Deferred.await(releaseFailure);
76 yield* Effect.fail("boom");77 return yield* Effect.fail("boom");
77 }78 }
7879
79 if (value === "second") {80 if (value === "second") {
packages/shared/src/KeyedCoalescingWorker.ts+4-2
...@@ -7,8 +7,10 @@...@@ -7,8 +7,10 @@
7 *7 *
8 * @module KeyedCoalescingWorker8 * @module KeyedCoalescingWorker
9 */9 */
10import type { Scope } from "effect";10import * as Scope from "effect/Scope";
11import { Effect, TxQueue, TxRef } from "effect";11import * as Effect from "effect/Effect";
12import * as TxQueue from "effect/TxQueue";
13import * as TxRef from "effect/TxRef";
1214
13export interface KeyedCoalescingWorker<K, V> {15export interface KeyedCoalescingWorker<K, V> {
14 readonly enqueue: (key: K, value: V) => Effect.Effect<void>;16 readonly enqueue: (key: K, value: V) => Effect.Effect<void>;
packages/shared/src/Net.test.ts+15-15
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import * as Net from "node:net";1import * as NodeNet from "node:net";
22
3import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
4import { Effect } from "effect";4import * as Effect from "effect/Effect";
55
6import { NetError, NetService } from "./Net.ts";6import * as Net from "./Net.ts";
77
8const closeServer = (server: Net.Server) =>8const closeServer = (server: NodeNet.Server) =>
9 Effect.sync(() => {9 Effect.sync(() => {
10 try {10 try {
11 server.close();11 server.close();
...@@ -14,24 +14,24 @@ const closeServer = (server: Net.Server) =>...@@ -14,24 +14,24 @@ const closeServer = (server: Net.Server) =>
14 }14 }
15 });15 });
1616
17const getPort = (server: Net.Server): number => {17const getPort = (server: NodeNet.Server): number => {
18 const address = server.address();18 const address = server.address();
19 return typeof address === "object" && address !== null ? address.port : 0;19 return typeof address === "object" && address !== null ? address.port : 0;
20};20};
2121
22const openServer = (host?: string): Effect.Effect<Net.Server, NetError> =>22const openServer = (host?: string): Effect.Effect<NodeNet.Server, Net.NetError> =>
23 Effect.callback<Net.Server, NetError>((resume) => {23 Effect.callback<NodeNet.Server, Net.NetError>((resume) => {
24 const server = Net.createServer();24 const server = NodeNet.createServer();
25 let settled = false;25 let settled = false;
2626
27 const settle = (effect: Effect.Effect<Net.Server, NetError>) => {27 const settle = (effect: Effect.Effect<NodeNet.Server, Net.NetError>) => {
28 if (settled) return;28 if (settled) return;
29 settled = true;29 settled = true;
30 resume(effect);30 resume(effect);
31 };31 };
3232
33 server.once("error", (cause) => {33 server.once("error", (cause) => {
34 settle(Effect.fail(new NetError({ message: "Failed to open test server", cause })));34 settle(Effect.fail(new Net.NetError({ message: "Failed to open test server", cause })));
35 });35 });
3636
37 if (host) {37 if (host) {
...@@ -43,11 +43,11 @@ const openServer = (host?: string): Effect.Effect<Net.Server, NetError> =>...@@ -43,11 +43,11 @@ const openServer = (host?: string): Effect.Effect<Net.Server, NetError> =>
43 return closeServer(server);43 return closeServer(server);
44 });44 });
4545
46it.layer(NetService.layer)("NetService", (it) => {46it.layer(Net.NetService.layer)("NetService", (it) => {
47 describe("Net helpers", () => {47 describe("Net helpers", () => {
48 it.effect("reserveLoopbackPort returns a positive loopback port", () =>48 it.effect("reserveLoopbackPort returns a positive loopback port", () =>
49 Effect.gen(function* () {49 Effect.gen(function* () {
50 const net = yield* NetService;50 const net = yield* Net.NetService;
51 const port = yield* net.reserveLoopbackPort();51 const port = yield* net.reserveLoopbackPort();
5252
53 assert.ok(port > 0);53 assert.ok(port > 0);
...@@ -59,7 +59,7 @@ it.layer(NetService.layer)("NetService", (it) => {...@@ -59,7 +59,7 @@ it.layer(NetService.layer)("NetService", (it) => {
59 openServer("127.0.0.1"),59 openServer("127.0.0.1"),
60 (server) =>60 (server) =>
61 Effect.gen(function* () {61 Effect.gen(function* () {
62 const net = yield* NetService;62 const net = yield* Net.NetService;
63 const port = getPort(server);63 const port = getPort(server);
6464
65 const available = yield* net.isPortAvailableOnLoopback(port);65 const available = yield* net.isPortAvailableOnLoopback(port);
...@@ -71,7 +71,7 @@ it.layer(NetService.layer)("NetService", (it) => {...@@ -71,7 +71,7 @@ it.layer(NetService.layer)("NetService", (it) => {
7171
72 it.effect("findAvailablePort returns preferred when it is free", () =>72 it.effect("findAvailablePort returns preferred when it is free", () =>
73 Effect.gen(function* () {73 Effect.gen(function* () {
74 const net = yield* NetService;74 const net = yield* Net.NetService;
75 const preferred = yield* net.reserveLoopbackPort();75 const preferred = yield* net.reserveLoopbackPort();
7676
77 const resolved = yield* net.findAvailablePort(preferred);77 const resolved = yield* net.findAvailablePort(preferred);
...@@ -84,7 +84,7 @@ it.layer(NetService.layer)("NetService", (it) => {...@@ -84,7 +84,7 @@ it.layer(NetService.layer)("NetService", (it) => {
84 openServer(),84 openServer(),
85 (server) =>85 (server) =>
86 Effect.gen(function* () {86 Effect.gen(function* () {
87 const net = yield* NetService;87 const net = yield* Net.NetService;
88 const preferred = getPort(server);88 const preferred = getPort(server);
8989
90 const resolved = yield* net.findAvailablePort(preferred);90 const resolved = yield* net.findAvailablePort(preferred);
packages/shared/src/Net.ts+95-88
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import * as Net from "node:net";1import * as NodeNet from "node:net";
22
3import { Data, Effect, Layer, Context } from "effect";3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Context from "effect/Context";
47
5export class NetError extends Data.TaggedError("NetError")<{8export class NetError extends Data.TaggedError("NetError")<{
6 readonly message: string;9 readonly message: string;
...@@ -18,7 +21,7 @@ function isErrnoExceptionWithCode(cause: unknown): cause is {...@@ -18,7 +21,7 @@ function isErrnoExceptionWithCode(cause: unknown): cause is {
18 );21 );
19}22}
2023
21const closeServer = (server: Net.Server) => {24const closeServer = (server: NodeNet.Server) => {
22 try {25 try {
23 server.close();26 server.close();
24 } catch {27 } catch {
...@@ -28,7 +31,7 @@ const closeServer = (server: Net.Server) => {...@@ -28,7 +31,7 @@ const closeServer = (server: Net.Server) => {
2831
29const tryReservePort = (port: number): Effect.Effect<number, NetError> =>32const tryReservePort = (port: number): Effect.Effect<number, NetError> =>
30 Effect.callback<number, NetError>((resume) => {33 Effect.callback<number, NetError>((resume) => {
31 const server = Net.createServer();34 const server = NodeNet.createServer();
32 let settled = false;35 let settled = false;
3336
34 const settle = (effect: Effect.Effect<number, NetError>) => {37 const settle = (effect: Effect.Effect<number, NetError>) => {
...@@ -82,99 +85,103 @@ export interface NetServiceShape {...@@ -82,99 +85,103 @@ export interface NetServiceShape {
82 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>;85 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>;
83}86}
8487
85/**88export const make = () => {
86 * NetService - Service tag for startup networking helpers.89 /**
87 */90 * Returns true when a TCP server can bind to {host, port}.
88export class NetService extends Context.Service<NetService, NetServiceShape>()(91 * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail
89 "@t3tools/shared/Net/NetService",92 * loopback availability checks.
90) {93 */
91 static readonly layer = Layer.sync(NetService, () => {94 const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> =>
92 /**95 Effect.callback<boolean>((resume) => {
93 * Returns true when a TCP server can bind to {host, port}.96 const server = NodeNet.createServer();
94 * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail97 let settled = false;
95 * loopback availability checks.98
96 */99 const settle = (value: boolean) => {
97 const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> =>100 if (settled) return;
98 Effect.callback<boolean>((resume) => {101 settled = true;
99 const server = Net.createServer();102 resume(Effect.succeed(value));
100 let settled = false;103 };
101104
102 const settle = (value: boolean) => {105 server.unref();
103 if (settled) return;106
104 settled = true;107 server.once("error", (cause) => {
105 resume(Effect.succeed(value));108 if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") {
106 };109 settle(true);
107110 return;
108 server.unref();111 }
109112 settle(false);
110 server.once("error", (cause) => {113 });
111 if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") {
112 settle(true);
113 return;
114 }
115 settle(false);
116 });
117114
118 server.once("listening", () => {115 server.once("listening", () => {
119 server.close(() => {116 server.close(() => {
120 settle(true);117 settle(true);
121 });
122 });118 });
119 });
123120
124 server.listen({ host, port });121 server.listen({ host, port });
125122
126 return Effect.sync(() => {123 return Effect.sync(() => {
127 closeServer(server);124 closeServer(server);
128 });
129 });125 });
126 });
130127
131 /**128 /**
132 * Reserve an ephemeral loopback port and release it immediately.129 * Reserve an ephemeral loopback port and release it immediately.
133 * Returns the reserved port number.130 * Returns the reserved port number.
134 */131 */
135 const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> =>132 const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> =>
136 Effect.callback<number, NetError>((resume) => {133 Effect.callback<number, NetError>((resume) => {
137 const probe = Net.createServer();134 const probe = NodeNet.createServer();
138 let settled = false;135 let settled = false;
139136
140 const settle = (effect: Effect.Effect<number, NetError>) => {137 const settle = (effect: Effect.Effect<number, NetError>) => {
141 if (settled) return;138 if (settled) return;
142 settled = true;139 settled = true;
143 resume(effect);140 resume(effect);
144 };141 };
145142
146 probe.once("error", (cause) => {143 probe.once("error", (cause) => {
147 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause })));144 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause })));
148 });145 });
149146
150 probe.listen(0, host, () => {147 probe.listen(0, host, () => {
151 const address = probe.address();148 const address = probe.address();
152 const port = typeof address === "object" && address !== null ? address.port : 0;149 const port = typeof address === "object" && address !== null ? address.port : 0;
153 probe.close(() => {150 probe.close(() => {
154 if (port > 0) {151 if (port > 0) {
155 settle(Effect.succeed(port));152 settle(Effect.succeed(port));
156 return;153 return;
157 }154 }
158 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" })));155 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" })));
159 });
160 });156 });
157 });
161158
162 return Effect.sync(() => {159 return Effect.sync(() => {
163 closeServer(probe);160 closeServer(probe);
164 });
165 });161 });
162 });
166163
167 return {164 return {
168 canListenOnHost,165 canListenOnHost,
169 isPortAvailableOnLoopback: (port) =>166 isPortAvailableOnLoopback: (port) =>
170 Effect.zipWith(167 Effect.zipWith(
171 canListenOnHost(port, "127.0.0.1"),168 canListenOnHost(port, "127.0.0.1"),
172 canListenOnHost(port, "::1"),169 canListenOnHost(port, "::1"),
173 (ipv4, ipv6) => ipv4 && ipv6,170 (ipv4, ipv6) => ipv4 && ipv6,
174 ),171 ),
175 reserveLoopbackPort,172 reserveLoopbackPort,
176 findAvailablePort: (preferred) =>173 findAvailablePort: (preferred) =>
177 Effect.catch(tryReservePort(preferred), () => tryReservePort(0)),174 Effect.catch(tryReservePort(preferred), () => tryReservePort(0)),
178 } satisfies NetServiceShape;175 } satisfies NetServiceShape;
179 });176};
177
178/**
179 * NetService - Service tag for startup networking helpers.
180 */
181export class NetService extends Context.Service<NetService, NetServiceShape>()(
182 "@t3tools/shared/Net/NetService",
183) {
184 static readonly layer = Layer.sync(NetService, make);
180}185}
186
187export const layer = NetService.layer;
packages/shared/src/observability.test.ts created+314
...@@ -0,0 +1,314 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { assert, describe, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
9import * as References from "effect/References";
10import * as Schema from "effect/Schema";
11import * as Tracer from "effect/Tracer";
12
13import {
14 compactTraceAttributes,
15 makeLocalFileTracer,
16 makeTraceSink,
17 type TraceRecord,
18} from "./observability.ts";
19
20const TraceRecordLine = Schema.Struct({
21 name: Schema.String,
22 spanId: Schema.String,
23 parentSpanId: Schema.optional(Schema.String),
24 attributes: Schema.Record(Schema.String, Schema.Unknown),
25 events: Schema.Array(
26 Schema.Struct({
27 name: Schema.String,
28 attributes: Schema.Record(Schema.String, Schema.Unknown),
29 }),
30 ),
31 exit: Schema.optional(
32 Schema.Struct({
33 _tag: Schema.String,
34 }),
35 ),
36});
37
38const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine));
39
40const makeRecord = (name: string, suffix = ""): TraceRecord => ({
41 type: "effect-span",
42 name,
43 traceId: `trace-${name}-${suffix}`,
44 spanId: `span-${name}-${suffix}`,
45 sampled: true,
46 kind: "internal",
47 startTimeUnixNano: "1",
48 endTimeUnixNano: "2",
49 durationMs: 1,
50 attributes: {
51 payload: suffix,
52 },
53 events: [],
54 links: [],
55 exit: {
56 _tag: "Success",
57 },
58});
59
60const readTraceRecords = (tracePath: string) =>
61 fs
62 .readFileSync(tracePath, "utf8")
63 .trim()
64 .split("\n")
65 .filter((line) => line.length > 0)
66 .map((line) => decodeTraceRecordLine(line));
67
68const makeTestLayer = (tracePath: string) =>
69 Layer.mergeAll(
70 Layer.effect(
71 Tracer.Tracer,
72 makeLocalFileTracer({
73 filePath: tracePath,
74 maxBytes: 1024 * 1024,
75 maxFiles: 2,
76 batchWindowMs: 10_000,
77 }),
78 ),
79 Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }),
80 Layer.succeed(References.MinimumLogLevel, "Info"),
81 );
82
83describe("observability", () => {
84 it("normalizes circular arrays, maps, and sets without recursing forever", () => {
85 const array: Array<unknown> = ["alpha"];
86 array.push(array);
87
88 const map = new Map<string, unknown>();
89 map.set("self", map);
90
91 const set = new Set<unknown>();
92 set.add(set);
93
94 assert.deepStrictEqual(
95 compactTraceAttributes({
96 array,
97 map,
98 set,
99 }),
100 {
101 array: ["alpha", "[Circular]"],
102 map: { self: "[Circular]" },
103 set: ["[Circular]"],
104 },
105 );
106 });
107
108 it("normalizes invalid dates without throwing", () => {
109 assert.deepStrictEqual(
110 compactTraceAttributes({
111 invalidDate: new Date("not-a-real-date"),
112 }),
113 {
114 invalidDate: "Invalid Date",
115 },
116 );
117 });
118
119 it.effect("flushes buffered trace records on close", () =>
120 Effect.scoped(
121 Effect.gen(function* () {
122 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
123 const tracePath = path.join(tempDir, "shared.trace.ndjson");
124
125 try {
126 const sink = yield* makeTraceSink({
127 filePath: tracePath,
128 maxBytes: 1024,
129 maxFiles: 2,
130 batchWindowMs: 10_000,
131 });
132
133 sink.push(makeRecord("alpha"));
134 sink.push(makeRecord("beta"));
135 yield* sink.close();
136
137 const lines = readTraceRecords(tracePath);
138
139 assert.equal(lines.length, 2);
140 assert.equal(lines[0]?.name, "alpha");
141 assert.equal(lines[1]?.name, "beta");
142 } finally {
143 fs.rmSync(tempDir, { recursive: true, force: true });
144 }
145 }),
146 ),
147 );
148
149 it.effect("rotates the trace file when the configured max size is exceeded", () =>
150 Effect.scoped(
151 Effect.gen(function* () {
152 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
153 const tracePath = path.join(tempDir, "shared.trace.ndjson");
154
155 try {
156 const sink = yield* makeTraceSink({
157 filePath: tracePath,
158 maxBytes: 180,
159 maxFiles: 2,
160 batchWindowMs: 10_000,
161 });
162
163 for (let index = 0; index < 8; index += 1) {
164 sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`));
165 yield* sink.flush;
166 }
167 yield* sink.close();
168
169 const matchingFiles = fs
170 .readdirSync(tempDir)
171 .filter(
172 (entry) =>
173 entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."),
174 )
175 .toSorted();
176
177 assert.equal(
178 matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"),
179 true,
180 );
181 assert.equal(
182 matchingFiles.some((entry) => entry === "shared.trace.ndjson.3"),
183 false,
184 );
185 } finally {
186 fs.rmSync(tempDir, { recursive: true, force: true });
187 }
188 }),
189 ),
190 );
191
192 it.effect("drops only the invalid trace record when serialization fails", () =>
193 Effect.scoped(
194 Effect.gen(function* () {
195 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
196 const tracePath = path.join(tempDir, "shared.trace.ndjson");
197
198 try {
199 const sink = yield* makeTraceSink({
200 filePath: tracePath,
201 maxBytes: 1024,
202 maxFiles: 2,
203 batchWindowMs: 10_000,
204 });
205
206 const circular: Array<unknown> = [];
207 circular.push(circular);
208
209 sink.push(makeRecord("alpha"));
210 sink.push({
211 ...makeRecord("invalid"),
212 attributes: {
213 circular,
214 },
215 } as TraceRecord);
216 sink.push(makeRecord("beta"));
217 yield* sink.close();
218
219 const lines = readTraceRecords(tracePath);
220
221 assert.deepStrictEqual(
222 lines.map((line) => line.name),
223 ["alpha", "beta"],
224 );
225 } finally {
226 fs.rmSync(tempDir, { recursive: true, force: true });
227 }
228 }),
229 ),
230 );
231
232 it.effect("writes nested spans to disk and captures log messages as span events", () =>
233 Effect.gen(function* () {
234 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-"));
235 const tracePath = path.join(tempDir, "shared.trace.ndjson");
236
237 try {
238 yield* Effect.scoped(
239 Effect.gen(function* () {
240 const program = Effect.gen(function* () {
241 yield* Effect.annotateCurrentSpan({
242 "demo.parent": true,
243 });
244 yield* Effect.logInfo("parent event");
245 yield* Effect.gen(function* () {
246 yield* Effect.annotateCurrentSpan({
247 "demo.child": true,
248 });
249 yield* Effect.logInfo("child event");
250 }).pipe(Effect.withSpan("child-span"));
251 }).pipe(Effect.withSpan("parent-span"));
252
253 yield* program.pipe(Effect.provide(makeTestLayer(tracePath)));
254 }),
255 );
256
257 const records = readTraceRecords(tracePath);
258 assert.equal(records.length, 2);
259
260 const parent = records.find((record) => record.name === "parent-span");
261 const child = records.find((record) => record.name === "child-span");
262
263 assert.notEqual(parent, undefined);
264 assert.notEqual(child, undefined);
265 if (!parent || !child) {
266 return;
267 }
268
269 assert.equal(child.parentSpanId, parent.spanId);
270 assert.equal(parent.attributes["demo.parent"], true);
271 assert.equal(child.attributes["demo.child"], true);
272 assert.equal(
273 parent.events.some((event) => event.name === "parent event"),
274 true,
275 );
276 assert.equal(
277 child.events.some((event) => event.name === "child event"),
278 true,
279 );
280 assert.equal(
281 child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"),
282 true,
283 );
284 } finally {
285 fs.rmSync(tempDir, { recursive: true, force: true });
286 }
287 }),
288 );
289
290 it.effect("serializes interrupted spans with an interrupted exit status", () =>
291 Effect.gen(function* () {
292 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-"));
293 const tracePath = path.join(tempDir, "shared.trace.ndjson");
294
295 try {
296 yield* Effect.scoped(
297 Effect.exit(
298 Effect.interrupt.pipe(
299 Effect.withSpan("interrupt-span"),
300 Effect.provide(makeTestLayer(tracePath)),
301 ),
302 ),
303 );
304
305 const records = readTraceRecords(tracePath);
306 assert.equal(records.length, 1);
307 assert.equal(records[0]?.name, "interrupt-span");
308 assert.equal(records[0]?.exit?._tag, "Interrupted");
309 } finally {
310 fs.rmSync(tempDir, { recursive: true, force: true });
311 }
312 }),
313 );
314});
packages/shared/src/observability.ts created+539
...@@ -0,0 +1,539 @@
1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import type * as Exit from "effect/Exit";
4import * as ExitRuntime from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Tracer from "effect/Tracer";
7import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
8
9import { RotatingFileSink } from "./logging.ts";
10
11const FLUSH_BUFFER_THRESHOLD = 32;
12
13export type TraceAttributes = Readonly<Record<string, unknown>>;
14
15export interface TraceRecordEvent {
16 readonly name: string;
17 readonly timeUnixNano: string;
18 readonly attributes: Readonly<Record<string, unknown>>;
19}
20
21export interface TraceRecordLink {
22 readonly traceId: string;
23 readonly spanId: string;
24 readonly attributes: Readonly<Record<string, unknown>>;
25}
26
27interface BaseTraceRecord {
28 readonly name: string;
29 readonly kind: string;
30 readonly traceId: string;
31 readonly spanId: string;
32 readonly parentSpanId?: string;
33 readonly sampled: boolean;
34 readonly startTimeUnixNano: string;
35 readonly endTimeUnixNano: string;
36 readonly durationMs: number;
37 readonly attributes: Readonly<Record<string, unknown>>;
38 readonly events: ReadonlyArray<TraceRecordEvent>;
39 readonly links: ReadonlyArray<TraceRecordLink>;
40}
41
42export interface EffectTraceRecord extends BaseTraceRecord {
43 readonly type: "effect-span";
44 readonly exit:
45 | {
46 readonly _tag: "Success";
47 }
48 | {
49 readonly _tag: "Interrupted";
50 readonly cause: string;
51 }
52 | {
53 readonly _tag: "Failure";
54 readonly cause: string;
55 };
56}
57
58export interface OtlpTraceRecord extends BaseTraceRecord {
59 readonly type: "otlp-span";
60 readonly resourceAttributes: Readonly<Record<string, unknown>>;
61 readonly scope: Readonly<{
62 readonly name?: string;
63 readonly version?: string;
64 readonly attributes: Readonly<Record<string, unknown>>;
65 }>;
66 readonly status?:
67 | {
68 readonly code?: string;
69 readonly message?: string;
70 }
71 | undefined;
72}
73
74export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;
75
76export interface TraceSinkOptions {
77 readonly filePath: string;
78 readonly maxBytes: number;
79 readonly maxFiles: number;
80 readonly batchWindowMs: number;
81}
82
83export interface TraceSink {
84 readonly filePath: string;
85 push: (record: TraceRecord) => void;
86 flush: Effect.Effect<void>;
87 close: () => Effect.Effect<void>;
88}
89
90export interface LocalFileTracerOptions extends TraceSinkOptions {
91 readonly delegate?: Tracer.Tracer;
92 readonly sink?: TraceSink;
93}
94
95type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number];
96type OtlpSpanEvent = OtlpSpan["events"][number];
97type OtlpSpanLink = OtlpSpan["links"][number];
98type OtlpSpanStatus = OtlpSpan["status"];
99
100interface SerializableSpan {
101 readonly name: string;
102 readonly traceId: string;
103 readonly spanId: string;
104 readonly parent: Option.Option<Tracer.AnySpan>;
105 readonly status: Tracer.SpanStatus;
106 readonly sampled: boolean;
107 readonly kind: Tracer.SpanKind;
108 readonly attributes: ReadonlyMap<string, unknown>;
109 readonly links: ReadonlyArray<Tracer.SpanLink>;
110 readonly events: ReadonlyArray<
111 readonly [name: string, startTime: bigint, attributes: Record<string, unknown>]
112 >;
113}
114
115function isPlainObject(value: unknown): value is Record<string, unknown> {
116 return typeof value === "object" && value !== null && !Array.isArray(value);
117}
118
119function markSeen(value: object, seen: WeakSet<object>): boolean {
120 if (seen.has(value)) {
121 return true;
122 }
123 seen.add(value);
124 return false;
125}
126
127function normalizeJsonValue(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
128 if (
129 value === null ||
130 value === undefined ||
131 typeof value === "string" ||
132 typeof value === "number" ||
133 typeof value === "boolean"
134 ) {
135 return value ?? null;
136 }
137 if (typeof value === "bigint") {
138 return value.toString();
139 }
140 if (value instanceof Date) {
141 return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
142 }
143 if (value instanceof Error) {
144 return {
145 name: value.name,
146 message: value.message,
147 ...(value.stack ? { stack: value.stack } : {}),
148 };
149 }
150 if (Array.isArray(value)) {
151 if (markSeen(value, seen)) {
152 return "[Circular]";
153 }
154 return value.map((entry) => normalizeJsonValue(entry, seen));
155 }
156 if (value instanceof Map) {
157 if (markSeen(value, seen)) {
158 return "[Circular]";
159 }
160 return Object.fromEntries(
161 Array.from(value.entries(), ([key, entryValue]) => [
162 String(key),
163 normalizeJsonValue(entryValue, seen),
164 ]),
165 );
166 }
167 if (value instanceof Set) {
168 if (markSeen(value, seen)) {
169 return "[Circular]";
170 }
171 return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen));
172 }
173 if (!isPlainObject(value)) {
174 return String(value);
175 }
176 if (markSeen(value, seen)) {
177 return "[Circular]";
178 }
179 return Object.fromEntries(
180 Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]),
181 );
182}
183
184export function compactTraceAttributes(
185 attributes: Readonly<Record<string, unknown>>,
186): TraceAttributes {
187 return Object.fromEntries(
188 Object.entries(attributes)
189 .filter(([, value]) => value !== undefined)
190 .map(([key, value]) => [key, normalizeJsonValue(value)]),
191 );
192}
193
194function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] {
195 if (ExitRuntime.isSuccess(exit)) {
196 return { _tag: "Success" };
197 }
198 if (Cause.hasInterruptsOnly(exit.cause)) {
199 return {
200 _tag: "Interrupted",
201 cause: Cause.pretty(exit.cause),
202 };
203 }
204 return {
205 _tag: "Failure",
206 cause: Cause.pretty(exit.cause),
207 };
208}
209
210export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
211 const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>;
212 const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;
213
214 return {
215 type: "effect-span",
216 name: span.name,
217 traceId: span.traceId,
218 spanId: span.spanId,
219 ...(parentSpanId ? { parentSpanId } : {}),
220 sampled: span.sampled,
221 kind: span.kind,
222 startTimeUnixNano: String(status.startTime),
223 endTimeUnixNano: String(status.endTime),
224 durationMs: Number(status.endTime - status.startTime) / 1_000_000,
225 attributes: compactTraceAttributes(Object.fromEntries(span.attributes)),
226 events: span.events.map(([name, startTime, attributes]) => ({
227 name,
228 timeUnixNano: String(startTime),
229 attributes: compactTraceAttributes(attributes),
230 })),
231 links: span.links.map((link) => ({
232 traceId: link.span.traceId,
233 spanId: link.span.spanId,
234 attributes: compactTraceAttributes(link.attributes),
235 })),
236 exit: formatTraceExit(status.exit),
237 };
238}
239
240export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) {
241 const sink = new RotatingFileSink({
242 filePath: options.filePath,
243 maxBytes: options.maxBytes,
244 maxFiles: options.maxFiles,
245 });
246
247 let buffer: Array<string> = [];
248
249 const flushUnsafe = () => {
250 if (buffer.length === 0) {
251 return;
252 }
253
254 const chunk = buffer.join("");
255 buffer = [];
256
257 try {
258 sink.write(chunk);
259 } catch {
260 buffer.unshift(chunk);
261 }
262 };
263
264 const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false));
265
266 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
267 yield* Effect.forkScoped(
268 Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever),
269 );
270
271 return {
272 filePath: options.filePath,
273 push(record) {
274 try {
275 buffer.push(`${JSON.stringify(record)}\n`);
276 if (buffer.length >= FLUSH_BUFFER_THRESHOLD) {
277 flushUnsafe();
278 }
279 } catch {
280 return;
281 }
282 },
283 flush,
284 close: () => flush,
285 } satisfies TraceSink;
286});
287
288class LocalFileSpan implements Tracer.Span {
289 readonly _tag = "Span";
290 readonly name: string;
291 readonly spanId: string;
292 readonly traceId: string;
293 readonly parent: Option.Option<Tracer.AnySpan>;
294 readonly annotations: Tracer.Span["annotations"];
295 readonly links: Array<Tracer.SpanLink>;
296 readonly sampled: boolean;
297 readonly kind: Tracer.SpanKind;
298
299 status: Tracer.SpanStatus;
300 attributes: Map<string, unknown>;
301 events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>;
302 private readonly delegate: Tracer.Span;
303 private readonly push: (record: EffectTraceRecord) => void;
304
305 constructor(
306 options: Parameters<Tracer.Tracer["span"]>[0],
307 delegate: Tracer.Span,
308 push: (record: EffectTraceRecord) => void,
309 ) {
310 this.delegate = delegate;
311 this.push = push;
312 this.name = delegate.name;
313 this.spanId = delegate.spanId;
314 this.traceId = delegate.traceId;
315 this.parent = options.parent;
316 this.annotations = options.annotations;
317 this.links = [...options.links];
318 this.sampled = delegate.sampled;
319 this.kind = delegate.kind;
320 this.status = {
321 _tag: "Started",
322 startTime: options.startTime,
323 };
324 this.attributes = new Map();
325 this.events = [];
326 }
327
328 end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
329 this.status = {
330 _tag: "Ended",
331 startTime: this.status.startTime,
332 endTime,
333 exit,
334 };
335 this.delegate.end(endTime, exit);
336
337 if (this.sampled) {
338 this.push(spanToTraceRecord(this));
339 }
340 }
341
342 attribute(key: string, value: unknown): void {
343 this.attributes.set(key, value);
344 this.delegate.attribute(key, value);
345 }
346
347 event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
348 const nextAttributes = attributes ?? {};
349 this.events.push([name, startTime, nextAttributes]);
350 this.delegate.event(name, startTime, nextAttributes);
351 }
352
353 addLinks(links: ReadonlyArray<Tracer.SpanLink>): void {
354 this.links.push(...links);
355 this.delegate.addLinks(links);
356 }
357}
358
359export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
360 options: LocalFileTracerOptions,
361) {
362 const sink =
363 options.sink ??
364 (yield* makeTraceSink({
365 filePath: options.filePath,
366 maxBytes: options.maxBytes,
367 maxFiles: options.maxFiles,
368 batchWindowMs: options.batchWindowMs,
369 }));
370
371 const delegate =
372 options.delegate ??
373 Tracer.make({
374 span: (spanOptions) => new Tracer.NativeSpan(spanOptions),
375 });
376
377 return Tracer.make({
378 span(spanOptions) {
379 return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push);
380 },
381 ...(delegate.context ? { context: delegate.context } : {}),
382 });
383});
384
385const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = {
386 1: "internal",
387 2: "server",
388 3: "client",
389 4: "producer",
390 5: "consumer",
391};
392
393export function decodeOtlpTraceRecords(
394 payload: OtlpTracer.TraceData,
395): ReadonlyArray<OtlpTraceRecord> {
396 const records: Array<OtlpTraceRecord> = [];
397
398 for (const resourceSpan of payload.resourceSpans) {
399 const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []);
400
401 for (const scopeSpan of resourceSpan.scopeSpans) {
402 for (const span of scopeSpan.spans) {
403 records.push(
404 otlpSpanToTraceRecord({
405 resourceAttributes,
406 scopeAttributes: decodeAttributes(
407 "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes)
408 ? scopeSpan.scope.attributes
409 : [],
410 ),
411 scopeName: scopeSpan.scope.name,
412 scopeVersion:
413 "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string"
414 ? scopeSpan.scope.version
415 : undefined,
416 span,
417 }),
418 );
419 }
420 }
421 }
422
423 return records;
424}
425
426function otlpSpanToTraceRecord(input: {
427 readonly resourceAttributes: Readonly<Record<string, unknown>>;
428 readonly scopeAttributes: Readonly<Record<string, unknown>>;
429 readonly scopeName: string | undefined;
430 readonly scopeVersion: string | undefined;
431 readonly span: OtlpSpan;
432}): OtlpTraceRecord {
433 return {
434 type: "otlp-span",
435 name: input.span.name,
436 traceId: input.span.traceId,
437 spanId: input.span.spanId,
438 ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}),
439 sampled: true,
440 kind: normalizeSpanKind(input.span.kind),
441 startTimeUnixNano: input.span.startTimeUnixNano,
442 endTimeUnixNano: input.span.endTimeUnixNano,
443 durationMs:
444 Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) /
445 1_000_000,
446 attributes: decodeAttributes(input.span.attributes),
447 resourceAttributes: input.resourceAttributes,
448 scope: {
449 ...(input.scopeName ? { name: input.scopeName } : {}),
450 ...(input.scopeVersion ? { version: input.scopeVersion } : {}),
451 attributes: input.scopeAttributes,
452 },
453 events: decodeEvents(input.span.events),
454 links: decodeLinks(input.span.links),
455 status: decodeStatus(input.span.status),
456 };
457}
458
459function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] {
460 const code = String(input.code);
461 const message = input.message;
462
463 return {
464 code,
465 ...(message ? { message } : {}),
466 };
467}
468
469function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> {
470 return input.map((current) => ({
471 name: current.name,
472 timeUnixNano: current.timeUnixNano,
473 attributes: decodeAttributes(current.attributes),
474 }));
475}
476
477function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> {
478 return input.flatMap((current) => {
479 const traceId = current.traceId;
480 const spanId = current.spanId;
481 return {
482 traceId,
483 spanId,
484 attributes: decodeAttributes(current.attributes),
485 };
486 });
487}
488
489function decodeAttributes(
490 input: ReadonlyArray<OtlpResource.KeyValue>,
491): Readonly<Record<string, unknown>> {
492 const entries: Record<string, unknown> = {};
493
494 for (const attribute of input) {
495 entries[attribute.key] = decodeValue(attribute.value);
496 }
497
498 return compactTraceAttributes(entries);
499}
500
501function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {
502 if (input == null) {
503 return null;
504 }
505 if ("stringValue" in input) {
506 return input.stringValue;
507 }
508 if ("boolValue" in input) {
509 return input.boolValue;
510 }
511 if ("intValue" in input) {
512 return input.intValue;
513 }
514 if ("doubleValue" in input) {
515 return input.doubleValue;
516 }
517 if ("bytesValue" in input) {
518 return input.bytesValue;
519 }
520 if (input.arrayValue) {
521 return input.arrayValue.values.map((entry) => decodeValue(entry));
522 }
523 if (input.kvlistValue) {
524 return decodeAttributes(input.kvlistValue.values);
525 }
526 return null;
527}
528
529function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] {
530 return SPAN_KIND_MAP[input] || "internal";
531}
532
533function parseBigInt(input: string): bigint {
534 try {
535 return BigInt(input);
536 } catch {
537 return 0n;
538 }
539}
packages/shared/src/schemaJson.ts+9-11
...@@ -1,14 +1,12 @@...@@ -1,14 +1,12 @@
1import {1import * as Cause from "effect/Cause";
2 Cause,2import * as Effect from "effect/Effect";
3 Effect,3import * as Exit from "effect/Exit";
4 Exit,4import * as Option from "effect/Option";
5 Option,5import * as Result from "effect/Result";
6 Result,6import * as Schema from "effect/Schema";
7 Schema,7import * as SchemaGetter from "effect/SchemaGetter";
8 SchemaGetter,8import * as SchemaIssue from "effect/SchemaIssue";
9 SchemaIssue,9import * as SchemaTransformation from "effect/SchemaTransformation";
10 SchemaTransformation,
11} from "effect";
1210
13export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(11export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(
14 schema: S,12 schema: S,
packages/shared/src/serverSettings.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
2import { Schema } from "effect";2import * as Schema from "effect/Schema";
3import { deepMerge } from "./Struct.ts";3import { deepMerge } from "./Struct.ts";
4import { fromLenientJson } from "./schemaJson.ts";4import { fromLenientJson } from "./schemaJson.ts";
5import { createModelSelection } from "./model.ts";5import { createModelSelection } from "./model.ts";
packages/shared/src/shell.ts+2-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import * as OS from "node:os";1import * as NodeOS from "node:os";
2import { execFileSync } from "node:child_process";2import { execFileSync } from "node:child_process";
3import { accessSync, constants, statSync } from "node:fs";3import { accessSync, constants, statSync } from "node:fs";
4import { extname, join } from "node:path";4import { extname, join } from "node:path";
...@@ -32,7 +32,7 @@ function trimNonEmpty(value: string | null | undefined): string | undefined {...@@ -32,7 +32,7 @@ function trimNonEmpty(value: string | null | undefined): string | undefined {
3232
33function readUserLoginShell(): string | undefined {33function readUserLoginShell(): string | undefined {
34 try {34 try {
35 return trimNonEmpty(OS.userInfo().shell);35 return trimNonEmpty(NodeOS.userInfo().shell);
36 } catch {36 } catch {
37 return undefined;37 return undefined;
38 }38 }
packages/shared/tsconfig.json+14
...@@ -1,4 +1,18 @@...@@ -1,4 +1,18 @@
1{1{
2 "extends": "../../tsconfig.base.json",2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "plugins": [
5 {
6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node", "effect"],
8 "diagnosticSeverity": {
9 "importFromBarrel": "error",
10 "anyUnknownInErrorContext": "error",
11 "instanceOfSchema": "warning",
12 "deterministicKeys": "warning"
13 }
14 }
15 ]
16 },
3 "include": ["src"]17 "include": ["src"]
4}18}
packages/ssh/src/auth.test.ts+4-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import { Effect, FileSystem, Path } from "effect";3
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
47
5import {8import {
6 buildSshAskpassHelperDescriptor,9 buildSshAskpassHelperDescriptor,
packages/ssh/src/auth.ts+6-1
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { Context, Effect, FileSystem, Layer, Path, PlatformError } from "effect";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
27
3import { SshPasswordPromptError } from "./errors.ts";8import { SshPasswordPromptError } from "./errors.ts";
49
packages/ssh/src/command.test.ts+7-1
...@@ -1,6 +1,12 @@...@@ -1,6 +1,12 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect";3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
4import { TestClock } from "effect/testing";10import { TestClock } from "effect/testing";
5import { ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcessSpawner } from "effect/unstable/process";
612
packages/ssh/src/command.ts+7-1
...@@ -1,7 +1,13 @@...@@ -1,7 +1,13 @@
1import * as Crypto from "node:crypto";1import * as Crypto from "node:crypto";
22
3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";
4import { Duration, Effect, FileSystem, Option, Path, Scope, Stream } from "effect";4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Scope from "effect/Scope";
10import * as Stream from "effect/Stream";
5import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
612
7import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";13import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";
packages/ssh/src/config.test.ts+3-1
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import { Effect, FileSystem, Path } from "effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
46
5import {7import {
6 discoverSshHosts,8 discoverSshHosts,
packages/ssh/src/config.ts+4-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
22
3import { Effect, FileSystem, Path, PlatformError } from "effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
47
5import { SshHostDiscoveryError } from "./errors.ts";8import { SshHostDiscoveryError } from "./errors.ts";
69
packages/ssh/src/errors.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Data } from "effect";1import * as Data from "effect/Data";
22
3export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{3export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{
4 readonly message: string;4 readonly message: string;
packages/ssh/src/tunnel.test.ts+7-1
...@@ -1,7 +1,13 @@...@@ -1,7 +1,13 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { NetService } from "@t3tools/shared/Net";3import { NetService } from "@t3tools/shared/Net";
4import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect";4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Result from "effect/Result";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
5import { TestClock } from "effect/testing";11import { TestClock } from "effect/testing";
6import { HttpClient, HttpClientResponse } from "effect/unstable/http";12import { HttpClient, HttpClientResponse } from "effect/unstable/http";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
packages/ssh/src/tunnel.ts+20-18
...@@ -4,22 +4,20 @@ import type {...@@ -4,22 +4,20 @@ import type {
4} from "@t3tools/contracts";4} from "@t3tools/contracts";
5import { type NetError, NetService } from "@t3tools/shared/Net";5import { type NetError, NetService } from "@t3tools/shared/Net";
6import { fromLenientJson } from "@t3tools/shared/schemaJson";6import { fromLenientJson } from "@t3tools/shared/schemaJson";
7import {7import * as Context from "effect/Context";
8 Deferred,8import * as Deferred from "effect/Deferred";
9 Context,9import * as Duration from "effect/Duration";
10 Duration,10import * as Effect from "effect/Effect";
11 Effect,11import * as Exit from "effect/Exit";
12 Exit,12import * as FileSystem from "effect/FileSystem";
13 FileSystem,13import * as Layer from "effect/Layer";
14 Layer,14import * as Option from "effect/Option";
15 Option,15import * as Path from "effect/Path";
16 Path,16import * as Ref from "effect/Ref";
17 Ref,17import * as Schedule from "effect/Schedule";
18 Schema,18import * as Schema from "effect/Schema";
19 Scope,19import * as Scope from "effect/Scope";
20 Schedule,20import * as Stream from "effect/Stream";
21 Stream,
22} from "effect";
23import { HttpClient, HttpClientRequest } from "effect/unstable/http";21import { HttpClient, HttpClientRequest } from "effect/unstable/http";
24import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";22import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2523
...@@ -64,7 +62,7 @@ export interface RemoteT3RunnerOptions {...@@ -64,7 +62,7 @@ export interface RemoteT3RunnerOptions {
6462
65export interface SshEnvironmentManagerOptions {63export interface SshEnvironmentManagerOptions {
66 readonly resolveCliPackageSpec?: () => string;64 readonly resolveCliPackageSpec?: () => string;
67 readonly resolveCliRunner?: () => RemoteT3RunnerOptions;65 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;
68}66}
6967
70interface SshTunnelEntry {68interface SshTunnelEntry {
...@@ -1502,7 +1500,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1502,7 +1500,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1502 });1500 });
1503 const packageSpec = options.resolveCliPackageSpec?.();1501 const packageSpec = options.resolveCliPackageSpec?.();
1504 const runner =1502 const runner =
1505 options.resolveCliRunner?.() ?? (packageSpec === undefined ? undefined : { packageSpec });1503 options.resolveCliRunner === undefined
1504 ? packageSpec === undefined
1505 ? undefined
1506 : { packageSpec }
1507 : yield* options.resolveCliRunner;
1506 yield* Effect.logDebug("ssh.environment.runner.resolved", {1508 yield* Effect.logDebug("ssh.environment.runner.resolved", {
1507 ...sshTargetLogFields(resolvedTarget),1509 ...sshTargetLogFields(resolvedTarget),
1508 ...sshRunnerLogFields(runner),1510 ...sshRunnerLogFields(runner),
packages/ssh/tsconfig.json+2-1
...@@ -4,9 +4,10 @@...@@ -4,9 +4,10 @@
4 "plugins": [4 "plugins": [
5 {5 {
6 "name": "@effect/language-service",6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node", "effect"],
7 "diagnosticSeverity": {8 "diagnosticSeverity": {
8 "importFromBarrel": "error",9 "importFromBarrel": "error",
9 "anyUnknownInErrorContext": "warning",10 "anyUnknownInErrorContext": "error",
10 "instanceOfSchema": "warning",11 "instanceOfSchema": "warning",
11 "deterministicKeys": "warning"12 "deterministicKeys": "warning"
12 }13 }
packages/tailscale/src/tailscale.test.ts+4-1
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import { Effect, Layer, Sink, Stream } from "effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
3import { ChildProcessSpawner } from "effect/unstable/process";6import { ChildProcessSpawner } from "effect/unstable/process";
47
5import {8import {
packages/tailscale/src/tailscale.ts+5-1
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { Data, Effect, Option, Schema, Stream } from "effect";1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";6import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
48