| ... | ... | @@ -1,43 +1,179 @@ |
| 1 | | import { spawnSync } from "node:child_process"; |
| 2 | | |
| 3 | | import electronmon from "electronmon"; |
| 1 | import { spawn, spawnSync } from "node:child_process"; |
| 2 | import { watch } from "node:fs"; |
| 3 | import { join } from "node:path"; |
| 4 | 4 | import waitOn from "wait-on"; |
| 5 | 5 | |
| 6 | 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 7 | 7 | |
| 8 | 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); |
| 9 | 9 | const devServerUrl = `http://localhost:${port}`; |
| 10 | const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"]; |
| 11 | const watchedDirectories = [ |
| 12 | { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, |
| 13 | { directory: "../server/dist", files: new Set(["index.mjs"]) }, |
| 14 | ]; |
| 15 | const forcedShutdownTimeoutMs = 1_500; |
| 16 | const restartDebounceMs = 120; |
| 17 | const workspaceDir = join(desktopDir, "..", ".."); |
| 10 | 18 | |
| 11 | 19 | await waitOn({ |
| 12 | | resources: [ |
| 13 | | `tcp:${port}`, |
| 14 | | "file:dist-electron/main.js", |
| 15 | | "file:dist-electron/preload.js", |
| 16 | | "file:../server/dist/index.mjs", |
| 17 | | ], |
| 20 | resources: [`tcp:${port}`, ...requiredFiles.map((filePath) => `file:${filePath}`)], |
| 18 | 21 | }); |
| 19 | 22 | |
| 20 | 23 | const childEnv = { ...process.env }; |
| 21 | 24 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 22 | 25 | |
| 23 | | const monitor = await electronmon({ |
| 24 | | cwd: desktopDir, |
| 25 | | args: ["dist-electron/main.js"], |
| 26 | | env: { |
| 27 | | ...childEnv, |
| 28 | | VITE_DEV_SERVER_URL: devServerUrl, |
| 29 | | }, |
| 30 | | electronPath: resolveElectronPath(), |
| 31 | | }); |
| 32 | | |
| 33 | 26 | let shuttingDown = false; |
| 27 | let restartTimer = null; |
| 28 | let currentApp = null; |
| 29 | let restartQueue = Promise.resolve(); |
| 30 | const expectedExits = new WeakSet(); |
| 31 | const watchers = []; |
| 32 | |
| 33 | function killChildTreeByPid(pid, signal) { |
| 34 | if (process.platform === "win32" || typeof pid !== "number") { |
| 35 | return; |
| 36 | } |
| 37 | |
| 38 | spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); |
| 39 | } |
| 40 | |
| 41 | function cleanupStaleDevApps() { |
| 42 | if (process.platform === "win32") { |
| 43 | return; |
| 44 | } |
| 45 | |
| 46 | const patterns = [ |
| 47 | `${workspaceDir}/node_modules/.bun/electronmon@.+/node_modules/electronmon/src/hook.js dist-electron/main.js`, |
| 48 | ]; |
| 49 | |
| 50 | for (const pattern of patterns) { |
| 51 | spawnSync("pkill", ["-f", pattern], { stdio: "ignore" }); |
| 52 | } |
| 53 | |
| 54 | spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore" }); |
| 55 | } |
| 56 | |
| 57 | function startApp() { |
| 58 | if (shuttingDown || currentApp !== null) { |
| 59 | return; |
| 60 | } |
| 61 | |
| 62 | const app = spawn( |
| 63 | resolveElectronPath(), |
| 64 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], |
| 65 | { |
| 66 | cwd: desktopDir, |
| 67 | env: { |
| 68 | ...childEnv, |
| 69 | VITE_DEV_SERVER_URL: devServerUrl, |
| 70 | }, |
| 71 | stdio: "inherit", |
| 72 | }, |
| 73 | ); |
| 74 | |
| 75 | currentApp = app; |
| 76 | |
| 77 | app.once("error", () => { |
| 78 | if (currentApp === app) { |
| 79 | currentApp = null; |
| 80 | } |
| 81 | |
| 82 | if (!shuttingDown) { |
| 83 | scheduleRestart(); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | app.once("exit", () => { |
| 88 | if (currentApp === app) { |
| 89 | currentApp = null; |
| 90 | } |
| 91 | |
| 92 | if (!shuttingDown && !expectedExits.has(app)) { |
| 93 | scheduleRestart(); |
| 94 | } |
| 95 | }); |
| 96 | } |
| 97 | |
| 98 | async function stopApp() { |
| 99 | const app = currentApp; |
| 100 | if (!app) { |
| 101 | return; |
| 102 | } |
| 103 | |
| 104 | currentApp = null; |
| 105 | expectedExits.add(app); |
| 106 | |
| 107 | await new Promise((resolve) => { |
| 108 | let settled = false; |
| 109 | |
| 110 | const finish = () => { |
| 111 | if (settled) { |
| 112 | return; |
| 113 | } |
| 114 | |
| 115 | settled = true; |
| 116 | resolve(); |
| 117 | }; |
| 118 | |
| 119 | app.once("exit", finish); |
| 120 | app.kill("SIGTERM"); |
| 121 | killChildTreeByPid(app.pid, "TERM"); |
| 122 | |
| 123 | setTimeout(() => { |
| 124 | if (settled) { |
| 125 | return; |
| 126 | } |
| 127 | |
| 128 | app.kill("SIGKILL"); |
| 129 | killChildTreeByPid(app.pid, "KILL"); |
| 130 | finish(); |
| 131 | }, forcedShutdownTimeoutMs).unref(); |
| 132 | }); |
| 133 | } |
| 134 | |
| 135 | function scheduleRestart() { |
| 136 | if (shuttingDown) { |
| 137 | return; |
| 138 | } |
| 139 | |
| 140 | if (restartTimer) { |
| 141 | clearTimeout(restartTimer); |
| 142 | } |
| 143 | |
| 144 | restartTimer = setTimeout(() => { |
| 145 | restartTimer = null; |
| 146 | restartQueue = restartQueue |
| 147 | .catch(() => undefined) |
| 148 | .then(async () => { |
| 149 | await stopApp(); |
| 150 | if (!shuttingDown) { |
| 151 | startApp(); |
| 152 | } |
| 153 | }); |
| 154 | }, restartDebounceMs); |
| 155 | } |
| 156 | |
| 157 | function startWatchers() { |
| 158 | for (const { directory, files } of watchedDirectories) { |
| 159 | const watcher = watch(join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { |
| 160 | if (typeof filename !== "string" || !files.has(filename)) { |
| 161 | return; |
| 162 | } |
| 163 | |
| 164 | scheduleRestart(); |
| 165 | }); |
| 166 | |
| 167 | watchers.push(watcher); |
| 168 | } |
| 169 | } |
| 34 | 170 | |
| 35 | 171 | function killChildTree(signal) { |
| 36 | 172 | if (process.platform === "win32") { |
| 37 | 173 | return; |
| 38 | 174 | } |
| 39 | 175 | |
| 40 | | // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. |
| 176 | // Kill direct children as a final fallback in case normal shutdown leaves stragglers. |
| 41 | 177 | spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); |
| 42 | 178 | } |
| 43 | 179 | |
| ... | ... | @@ -45,12 +181,16 @@ async function shutdown(exitCode) { |
| 45 | 181 | if (shuttingDown) return; |
| 46 | 182 | shuttingDown = true; |
| 47 | 183 | |
| 48 | | try { |
| 49 | | await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]); |
| 50 | | } catch { |
| 51 | | // Best effort only; fallback process kill below handles stubborn children. |
| 184 | if (restartTimer) { |
| 185 | clearTimeout(restartTimer); |
| 186 | restartTimer = null; |
| 52 | 187 | } |
| 53 | 188 | |
| 189 | for (const watcher of watchers) { |
| 190 | watcher.close(); |
| 191 | } |
| 192 | |
| 193 | await stopApp(); |
| 54 | 194 | killChildTree("TERM"); |
| 55 | 195 | setTimeout(() => { |
| 56 | 196 | killChildTree("KILL"); |
| ... | ... | @@ -59,6 +199,10 @@ async function shutdown(exitCode) { |
| 59 | 199 | process.exit(exitCode); |
| 60 | 200 | } |
| 61 | 201 | |
| 202 | startWatchers(); |
| 203 | cleanupStaleDevApps(); |
| 204 | startApp(); |
| 205 | |
| 62 | 206 | process.once("SIGINT", () => { |
| 63 | 207 | void shutdown(130); |
| 64 | 208 | }); |