1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import {
7 desktopDir,
8 resolveDevProtocolClient,
9 resolveElectronLaunchCommand,
10} from "./electron-launcher.mjs";
11import { waitForResources } from "./wait-for-resources.mjs";
12
13const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
14if (!devServerUrl) {
15 throw new Error("VITE_DEV_SERVER_URL is required for desktop development.");
16}
17
18const devServer = new URL(devServerUrl);
19const port = Number.parseInt(devServer.port, 10);
20if (!Number.isInteger(port) || port <= 0) {
21 throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`);
22}
23
24const requiredFiles = [
25 "dist-electron/main.cjs",
26 "dist-electron/electron/WindowsForegroundFocusWorker.cjs",
27 "dist-electron/preload.cjs",
28 "dist-electron/snapShot/GlobalShiftShortcutWorker.cjs",
29 "dist-electron/snapShot/RegionSnapShotWorker.cjs",
30 "dist-electron/snapShot/SnapShotAccessibilityWorker.cjs",
31 "../server/dist/bin.mjs",
32];
33const watchedDirectories = [
34 { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) },
35 {
36 directory: "dist-electron/electron",
37 files: new Set(["WindowsForegroundFocusWorker.cjs"]),
38 },
39 {
40 directory: "dist-electron/snapShot",
41 files: new Set([
42 "GlobalShiftShortcutWorker.cjs",
43 "RegionSnapShotWorker.cjs",
44 "SnapShotAccessibilityWorker.cjs",
45 ]),
46 },
47 { directory: "../server/dist", files: new Set(["bin.mjs"]) },
48];
49const forcedShutdownTimeoutMs = 1_500;
50const restartDebounceMs = 120;
51const childTreeGracePeriodMs = 1_200;
52const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
53// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
54const hostPlatform = NodeOS.platform();
55const parentPid = process.ppid;
56const parentWatchIntervalMs = 1_000;
57
58NodeChildProcess.execFileSync(
59 process.execPath,
60 [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
61 { stdio: "inherit" },
62);
63
64await waitForResources({
65 baseDir: desktopDir,
66 files: requiredFiles,
67 tcpHost: devServer.hostname,
68 tcpPort: port,
69});
70
71const childEnv = { ...process.env };
72delete childEnv.ELECTRON_RUN_AS_NODE;
73const devProtocolClient = resolveDevProtocolClient();
74if (devProtocolClient) {
75 childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
76 childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
77}
78
79let shuttingDown = false;
80let restartTimer = null;
81let currentApp = null;
82let restartQueue = Promise.resolve();
83const expectedExits = new WeakSet();
84const watchers = [];
85
86function killChildTreeByPid(pid, signal) {
87 if (hostPlatform === "win32" || typeof pid !== "number") {
88 return;
89 }
90
91 NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" });
92}
93
94function cleanupStaleDevApps() {
95 if (hostPlatform === "win32") {
96 return;
97 }
98
99 const escapedDesktopDir = desktopDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100 NodeChildProcess.spawnSync(
101 "pkill",
102 ["-f", "--", `(^| )--t3code-dev-root=${escapedDesktopDir}( |$)`],
103 {
104 stdio: "ignore",
105 },
106 );
107}
108
109function startApp() {
110 if (shuttingDown || currentApp !== null) {
111 return;
112 }
113
114 const electronArgs = remoteDebuggingPort
115 ? [`--remote-debugging-port=${remoteDebuggingPort}`]
116 : [];
117 const launchArgs = devProtocolClient
118 ? electronArgs
119 : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
120 const electronCommand = resolveElectronLaunchCommand(launchArgs);
121 const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, {
122 cwd: desktopDir,
123 env: childEnv,
124 stdio: "inherit",
125 });
126
127 currentApp = app;
128
129 app.once("error", () => {
130 if (currentApp === app) {
131 currentApp = null;
132 }
133
134 if (!shuttingDown) {
135 scheduleRestart();
136 }
137 });
138
139 app.once("exit", (code, signal) => {
140 if (currentApp === app) {
141 currentApp = null;
142 }
143
144 const exitedAbnormally = signal !== null || code !== 0;
145 if (!shuttingDown && !expectedExits.has(app) && exitedAbnormally) {
146 scheduleRestart();
147 }
148 });
149}
150
151async function stopApp() {
152 const app = currentApp;
153 if (!app) {
154 return;
155 }
156
157 currentApp = null;
158 expectedExits.add(app);
159
160 await new Promise((resolve) => {
161 let settled = false;
162
163 const finish = () => {
164 if (settled) {
165 return;
166 }
167
168 settled = true;
169 resolve();
170 };
171
172 app.once("exit", finish);
173 app.kill("SIGTERM");
174 killChildTreeByPid(app.pid, "TERM");
175 cleanupStaleDevApps();
176
177 setTimeout(() => {
178 if (settled) {
179 return;
180 }
181
182 app.kill("SIGKILL");
183 killChildTreeByPid(app.pid, "KILL");
184 cleanupStaleDevApps();
185 finish();
186 }, forcedShutdownTimeoutMs).unref();
187 });
188}
189
190function scheduleRestart() {
191 if (shuttingDown) {
192 return;
193 }
194
195 if (restartTimer) {
196 clearTimeout(restartTimer);
197 }
198
199 restartTimer = setTimeout(() => {
200 restartTimer = null;
201 restartQueue = restartQueue
202 .catch(() => undefined)
203 .then(async () => {
204 await stopApp();
205 if (!shuttingDown) {
206 startApp();
207 }
208 });
209 }, restartDebounceMs);
210}
211
212function startWatchers() {
213 for (const { directory, files } of watchedDirectories) {
214 const watcher = NodeFS.watch(
215 NodePath.join(desktopDir, directory),
216 { persistent: true },
217 (_eventType, filename) => {
218 if (typeof filename !== "string" || !files.has(filename)) {
219 return;
220 }
221
222 scheduleRestart();
223 },
224 );
225
226 watchers.push(watcher);
227 }
228}
229
230function killChildTree(signal) {
231 if (hostPlatform === "win32") {
232 return;
233 }
234
235 NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], {
236 stdio: "ignore",
237 });
238}
239
240async function shutdown(exitCode) {
241 if (shuttingDown) return;
242 shuttingDown = true;
243
244 if (restartTimer) {
245 clearTimeout(restartTimer);
246 restartTimer = null;
247 }
248
249 for (const watcher of watchers) {
250 watcher.close();
251 }
252
253 await stopApp();
254 killChildTree("TERM");
255 await new Promise((resolve) => {
256 setTimeout(resolve, childTreeGracePeriodMs);
257 });
258 killChildTree("KILL");
259
260 process.exit(exitCode);
261}
262
263startWatchers();
264cleanupStaleDevApps();
265startApp();
266
267process.once("SIGINT", () => {
268 void shutdown(130);
269});
270process.once("SIGTERM", () => {
271 void shutdown(143);
272});
273process.once("SIGHUP", () => {
274 void shutdown(129);
275});
276// A parent killed without forwarding a signal leaves us reparented (to a subreaper on Linux, not
277// necessarily PID 1), and nothing else would ever stop the restart loop. This only notices the
278// death of our direct parent, so vite.config.ts launches us without a shell in between. Windows
279// never reparents, and reading process.ppid there snapshots the process table.
280if (hostPlatform !== "win32") {
281 setInterval(() => {
282 if (process.ppid !== parentPid) {
283 void shutdown(129);
284 }
285 }, parentWatchIntervalMs).unref();
286}