| 1 | import * as NodeChildProcess from "node:child_process"; |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | |
| 6 | import { |
| 7 | desktopDir, |
| 8 | resolveDevProtocolClient, |
| 9 | resolveElectronLaunchCommand, |
| 10 | } from "./electron-launcher.mjs"; |
| 11 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 12 | |
| 13 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); |
| 14 | if (!devServerUrl) { |
| 15 | throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); |
| 16 | } |
| 17 | |
| 18 | const devServer = new URL(devServerUrl); |
| 19 | const port = Number.parseInt(devServer.port, 10); |
| 20 | if (!Number.isInteger(port) || port <= 0) { |
| 21 | throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); |
| 22 | } |
| 23 | |
| 24 | const 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 | ]; |
| 33 | const 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 | ]; |
| 49 | const forcedShutdownTimeoutMs = 1_500; |
| 50 | const restartDebounceMs = 120; |
| 51 | const childTreeGracePeriodMs = 1_200; |
| 52 | const 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. |
| 54 | const hostPlatform = NodeOS.platform(); |
| 55 | |
| 56 | NodeChildProcess.execFileSync( |
| 57 | process.execPath, |
| 58 | [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], |
| 59 | { stdio: "inherit" }, |
| 60 | ); |
| 61 | |
| 62 | await waitForResources({ |
| 63 | baseDir: desktopDir, |
| 64 | files: requiredFiles, |
| 65 | tcpHost: devServer.hostname, |
| 66 | tcpPort: port, |
| 67 | }); |
| 68 | |
| 69 | const childEnv = { ...process.env }; |
| 70 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 71 | const devProtocolClient = resolveDevProtocolClient(); |
| 72 | if (devProtocolClient) { |
| 73 | childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; |
| 74 | childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; |
| 75 | } |
| 76 | |
| 77 | let shuttingDown = false; |
| 78 | let restartTimer = null; |
| 79 | let currentApp = null; |
| 80 | let restartQueue = Promise.resolve(); |
| 81 | const expectedExits = new WeakSet(); |
| 82 | const watchers = []; |
| 83 | |
| 84 | function 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 | |
| 92 | function cleanupStaleDevApps() { |
| 93 | if (hostPlatform === "win32") { |
| 94 | return; |
| 95 | } |
| 96 | |
| 97 | NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { |
| 98 | stdio: "ignore", |
| 99 | }); |
| 100 | } |
| 101 | |
| 102 | function 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 | |
| 144 | async 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 | |
| 183 | function 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 | |
| 205 | function 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 | |
| 223 | function 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 | |
| 234 | async 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 | |
| 257 | startWatchers(); |
| 258 | cleanupStaleDevApps(); |
| 259 | startApp(); |
| 260 | |
| 261 | process.once("SIGINT", () => { |
| 262 | void shutdown(130); |
| 263 | }); |
| 264 | process.once("SIGTERM", () => { |
| 265 | void shutdown(143); |
| 266 | }); |
| 267 | process.once("SIGHUP", () => { |
| 268 | void shutdown(129); |
| 269 | }); |