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();
55
56NodeChildProcess.execFileSync(
57 process.execPath,
58 [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
59 { stdio: "inherit" },
60);
61
62await waitForResources({
63 baseDir: desktopDir,
64 files: requiredFiles,
65 tcpHost: devServer.hostname,
66 tcpPort: port,
67});
68
69const childEnv = { ...process.env };
70delete childEnv.ELECTRON_RUN_AS_NODE;
71const devProtocolClient = resolveDevProtocolClient();
72if (devProtocolClient) {
73 childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
74 childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
75}
76
77let shuttingDown = false;
78let restartTimer = null;
79let currentApp = null;
80let restartQueue = Promise.resolve();
81const expectedExits = new WeakSet();
82const watchers = [];
83
84function killChildTreeByPid(pid, signal) {
85 if (hostPlatform === "win32" || typeof pid !== "number") {
86 return;
87 }
88
89 NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" });
90}
91
92function cleanupStaleDevApps() {
93 if (hostPlatform === "win32") {
94 return;
95 }
96
97 NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], {
98 stdio: "ignore",
99 });
100}
101
102function startApp() {
103 if (shuttingDown || currentApp !== null) {
104 return;
105 }
106
107 const electronArgs = remoteDebuggingPort
108 ? [`--remote-debugging-port=${remoteDebuggingPort}`]
109 : [];
110 const launchArgs = devProtocolClient
111 ? electronArgs
112 : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
113 const electronCommand = resolveElectronLaunchCommand(launchArgs);
114 const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, {
115 cwd: desktopDir,
116 env: childEnv,
117 stdio: "inherit",
118 });
119
120 currentApp = app;
121
122 app.once("error", () => {
123 if (currentApp === app) {
124 currentApp = null;
125 }
126
127 if (!shuttingDown) {
128 scheduleRestart();
129 }
130 });
131
132 app.once("exit", (code, signal) => {
133 if (currentApp === app) {
134 currentApp = null;
135 }
136
137 const exitedAbnormally = signal !== null || code !== 0;
138 if (!shuttingDown && !expectedExits.has(app) && exitedAbnormally) {
139 scheduleRestart();
140 }
141 });
142}
143
144async function stopApp() {
145 const app = currentApp;
146 if (!app) {
147 return;
148 }
149
150 currentApp = null;
151 expectedExits.add(app);
152
153 await new Promise((resolve) => {
154 let settled = false;
155
156 const finish = () => {
157 if (settled) {
158 return;
159 }
160
161 settled = true;
162 resolve();
163 };
164
165 app.once("exit", finish);
166 app.kill("SIGTERM");
167 killChildTreeByPid(app.pid, "TERM");
168 cleanupStaleDevApps();
169
170 setTimeout(() => {
171 if (settled) {
172 return;
173 }
174
175 app.kill("SIGKILL");
176 killChildTreeByPid(app.pid, "KILL");
177 cleanupStaleDevApps();
178 finish();
179 }, forcedShutdownTimeoutMs).unref();
180 });
181}
182
183function scheduleRestart() {
184 if (shuttingDown) {
185 return;
186 }
187
188 if (restartTimer) {
189 clearTimeout(restartTimer);
190 }
191
192 restartTimer = setTimeout(() => {
193 restartTimer = null;
194 restartQueue = restartQueue
195 .catch(() => undefined)
196 .then(async () => {
197 await stopApp();
198 if (!shuttingDown) {
199 startApp();
200 }
201 });
202 }, restartDebounceMs);
203}
204
205function startWatchers() {
206 for (const { directory, files } of watchedDirectories) {
207 const watcher = NodeFS.watch(
208 NodePath.join(desktopDir, directory),
209 { persistent: true },
210 (_eventType, filename) => {
211 if (typeof filename !== "string" || !files.has(filename)) {
212 return;
213 }
214
215 scheduleRestart();
216 },
217 );
218
219 watchers.push(watcher);
220 }
221}
222
223function killChildTree(signal) {
224 if (hostPlatform === "win32") {
225 return;
226 }
227
228 // Kill direct children as a final fallback in case normal shutdown leaves stragglers.
229 NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], {
230 stdio: "ignore",
231 });
232}
233
234async function shutdown(exitCode) {
235 if (shuttingDown) return;
236 shuttingDown = true;
237
238 if (restartTimer) {
239 clearTimeout(restartTimer);
240 restartTimer = null;
241 }
242
243 for (const watcher of watchers) {
244 watcher.close();
245 }
246
247 await stopApp();
248 killChildTree("TERM");
249 await new Promise((resolve) => {
250 setTimeout(resolve, childTreeGracePeriodMs);
251 });
252 killChildTree("KILL");
253
254 process.exit(exitCode);
255}
256
257startWatchers();
258cleanupStaleDevApps();
259startApp();
260
261process.once("SIGINT", () => {
262 void shutdown(130);
263});
264process.once("SIGTERM", () => {
265 void shutdown(143);
266});
267process.once("SIGHUP", () => {
268 void shutdown(129);
269});