From f8565aecb0cf1426832ff4bd4045d81e74ddd766 Mon Sep 17 00:00:00 2001 From: Gaurav Chaudhary Date: Fri, 11 Sep 2026 17:23:30 +0530 Subject: [PATCH] Add desktop live reload and explicit local startup Refs #51 Signed-off-by: Gaurav Chaudhary --- README.md | 32 ++++++++++- capacitor.config.ts | 8 +++ e2e/desktop/smoke.spec.ts | 40 +++++++++++-- electron/main.ts | 37 +++++++++++- electron/package.json | 2 +- package.json | 5 +- scripts/dev-desktop.mjs | 116 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 scripts/dev-desktop.mjs diff --git a/README.md b/README.md index 98787979..1f51cf71 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,32 @@ downloading Electron for ordinary web/mobile installs: ```sh pnpm install --frozen-lockfile npm ci --prefix electron -pnpm run build:desktop pnpm run dev:desktop ``` +`dev:desktop` starts Vite in development mode on `127.0.0.1`, then runs the +Capawesome Electron platform against that server with the Capacitor plugin bridge +and frontend HMR. No previous frontend build is needed. It uses the existing Vite +port (1847 by default) and fails if the port is occupied. Quit another desktop +instance before switching modes; the platform allows one instance at a time. +Restart the command after editing Electron TypeScript. Quit Electron or press +Ctrl+C to stop the development environment. + +To build and run local production assets instead: + +```sh +pnpm run build:desktop +pnpm run start:desktop +``` + `build:desktop` builds the frontend without PWA/service-worker registration, synchronizes the Electron platform, and compiles its TypeScript entrypoint. It uses the same branding environment as the web build and does not synchronize Android -or iOS. `dev:desktop` opens those built assets; rerun `build:desktop` after frontend -changes. Live reload is deferred to the desktop configuration work. +or iOS. The existing build scripts require Bash, Perl, and their usual asset tools. +`start:desktop` opens the last build without Vite; rerun `build:desktop` after +frontend changes. The development URL is supplied only to the desktop run process. +Capawesome records it in ignored generated configuration during development; +production synchronization removes it, and local startup ignores inherited dev URLs. Run `pnpm run test:desktop` after building to check the Linux desktop window. On a headless Linux runner, use `xvfb-run -a pnpm run test:desktop`; Electron links @@ -79,6 +96,15 @@ root, because Chromium refuses to start that way. The separate smoke suite does not start a web dev server or test installers. Windows and macOS desktop behavior is not verified by the Linux test. CI does not run it. +When changing the desktop workflow, also verify `dev:desktop` in a disposable +checkout: change existing Svelte source and confirm HMR preserves a marker set on +`window` in DevTools, then restore the file. Check navigation, reload, blob workers, +and recovery after reloading while Vite restarts. Verify that Ctrl+C and quitting +Electron release the server port and leave no child processes, including on startup +failure or a TypeScript error. Finally stop development, rebuild, and confirm +`start:desktop` loads local assets with no Vite connection. These runtime checks +are separate from CI's lint and Electron TypeScript checks. + ## Deployment To run your own Flotilla, it's as simple as: diff --git a/capacitor.config.ts b/capacitor.config.ts index 0815f9e4..b7a7a0a7 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -32,4 +32,12 @@ const config: CapacitorConfig = { }, } +if ( + process.argv.includes("run") && + process.argv.includes("@capawesome/capacitor-electron") && + process.env.FLOTILLA_DESKTOP_DEV_URL +) { + config.server = {...config.server, url: process.env.FLOTILLA_DESKTOP_DEV_URL} +} + export default config diff --git a/e2e/desktop/smoke.spec.ts b/e2e/desktop/smoke.spec.ts index cd657feb..5229fb6f 100644 --- a/e2e/desktop/smoke.spec.ts +++ b/e2e/desktop/smoke.spec.ts @@ -11,13 +11,19 @@ test("the desktop baseline renders, navigates, and keeps external pages outside" const executablePath: string = createRequire(import.meta.url)( resolve("electron/node_modules/electron"), ) - const app = await _electron.launch({ + const options = { executablePath, // Chromium refuses to start as root with its sandbox on, which is what a CI container is. chromiumSandbox: process.getuid?.() !== 0, - args: [resolve("electron")], - env: {...process.env, XDG_CONFIG_HOME: profile}, - }) + args: [resolve("electron"), "--desktop-local"], + env: { + ...process.env, + XDG_CONFIG_HOME: profile, + FLOTILLA_DESKTOP_DEV_URL: "http://127.0.0.1:1/", + CAPACITOR_ELECTRON_DEV_SERVER_URL: "http://127.0.0.1:1/", + }, + } + const app = await _electron.launch(options) try { const mainWindows = () => @@ -25,8 +31,21 @@ test("the desktop baseline renders, navigates, and keeps external pages outside" await expect.poll(() => mainWindows().length).toBe(1) const [page] = mainWindows() const errors: string[] = [] + const developmentRequests: string[] = [] page.on("pageerror", error => errors.push(error.message)) + page.on("request", request => { + if (/127\.0\.0\.1:1|\/@vite\/|\/@id\/|\/@fs\//.test(request.url())) { + developmentRequests.push(request.url()) + } + }) + + expect( + await app.evaluate(({BrowserWindow}) => { + const window = BrowserWindow.getAllWindows()[0] + return {size: window.getSize(), resizable: window.isResizable()} + }), + ).toEqual({size: [1200, 800], resizable: true}) await page.reload() await expect(page.getByRole("heading")).toBeVisible() @@ -93,9 +112,22 @@ test("the desktop baseline renders, navigates, and keeps external pages outside" }) await expect(page.locator("html")).toHaveAttribute("data-csp-violation", "script-src-elem") await expect(page.locator("html")).not.toHaveAttribute("data-inline-script-executed") + expect(developmentRequests).toEqual([]) + + await app.evaluate(({BrowserWindow}) => BrowserWindow.getAllWindows()[0].setSize(1000, 700)) } finally { await app.close() } + + const reopened = await _electron.launch(options) + try { + await reopened.firstWindow() + expect( + await reopened.evaluate(({BrowserWindow}) => BrowserWindow.getAllWindows()[0].getSize()), + ).toEqual([1000, 700]) + } finally { + await reopened.close() + } } finally { await rm(profile, {recursive: true, force: true}) } diff --git a/electron/main.ts b/electron/main.ts index 5e97b1d9..2d27a677 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,7 +1,40 @@ +import {app} from "electron" import {createCapacitorElectronApp} from "@capawesome/capacitor-electron" +const devServerUrl = process.env.FLOTILLA_DESKTOP_DEV_URL + +// Capawesome 0.1.1 has no public option to force local assets over an inherited dev URL. +if (app.isPackaged || process.argv.includes("--desktop-local") || !devServerUrl) { + delete process.env.CAPACITOR_ELECTRON_DEV_SERVER_URL +} else { + const url = new URL(devServerUrl) + if ( + url.protocol !== "http:" || + url.hostname !== "127.0.0.1" || + devServerUrl !== `${url.origin}/` || + process.env.CAPACITOR_ELECTRON_DEV_SERVER_URL !== devServerUrl + ) { + throw new Error("Desktop development requires the matching loopback URL from cap run.") + } +} + createCapacitorElectronApp({ window: {width: 1200, height: 800}, - // SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap. - csp: {policy: "base-uri 'self'; object-src 'none'"}, + csp: { + // SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap. + policy: "base-uri 'self'; object-src 'none'", + // Capawesome 0.1.1's dev policy, plus Flotilla's analytics origin and blob workers. + devPolicy: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "media-src 'self' blob:", + "connect-src 'self' https: wss: ws: http:", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + ].join("; "), + }, }) diff --git a/electron/package.json b/electron/package.json index 788db736..736080a7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc", "postinstall": "install-electron", - "start": "electron ." + "start": "electron . --desktop-local" }, "devDependencies": { "electron": "^43.0.0", diff --git a/package.json b/package.json index cfe7c29f..8c0c0139 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,15 @@ "dev": "vite dev", "build": "./scripts/build.sh", "build:desktop": "bash scripts/build-desktop.sh", - "dev:desktop": "npm --prefix electron start", + "dev:desktop": "node scripts/dev-desktop.mjs", + "start:desktop": "npm --prefix electron start", "build:server": "vite build --config vite.config.server.ts", "start": "node server.js", "release:android": "./scripts/build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "bump": "node scripts/bump-version.mjs", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src e2e playwright.config.ts electron/*.ts && eslint src e2e electron/*.ts", + "lint": "prettier --check src e2e playwright.config.ts capacitor.config.ts electron/*.ts scripts/dev-desktop.mjs && eslint src e2e capacitor.config.ts electron/*.ts scripts/dev-desktop.mjs", "test": "playwright test", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:ui": "playwright test --ui", diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs new file mode 100644 index 00000000..1c6bef83 --- /dev/null +++ b/scripts/dev-desktop.mjs @@ -0,0 +1,116 @@ +import {spawn, spawnSync} from "node:child_process" +import {access, readFile} from "node:fs/promises" +import {createRequire} from "node:module" +import {dirname, resolve} from "node:path" +import {fileURLToPath} from "node:url" + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const require = createRequire(import.meta.url) +const windows = process.platform === "win32" +let server +let child +let interrupted = false +let forceShutdown + +const terminate = (signal = "SIGTERM") => { + if (child?.pid) { + if (windows) { + if (typeof child.exitCode !== "number" && !child.signalCode) { + const result = spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"]) + if (result.error || result.status !== 0) { + console.error(result.error || result.stderr.toString().trim()) + process.exitCode = 1 + } + } + } else { + try { + // cap run owns shell/npm descendants, so stopping just its PID leaves Electron running. + process.kill(-child.pid, signal) + } catch (error) { + if (error.code !== "ESRCH") { + console.error(error) + process.exitCode = 1 + } + } + } + } +} + +const onSignal = signal => { + interrupted = true + process.exitCode = signal === "SIGINT" ? 130 : 143 + terminate() + forceShutdown ??= setTimeout(() => terminate("SIGKILL"), 5000) + forceShutdown.unref() +} + +try { + try { + // Requiring Electron 43 downloads missing binaries; inspect the installation without running it. + const electron = resolve(root, "electron/node_modules/electron") + const executable = (await readFile(resolve(electron, "path.txt"), "utf8")).trim() + await access( + resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH || resolve(electron, "dist"), executable), + ) + } catch (error) { + throw new Error("Install the desktop dependencies first: npm ci --prefix electron", { + cause: error, + }) + } + + process.chdir(root) + process.env.NODE_ENV = "development" + process.env.FLOTILLA_DESKTOP = "1" + const {createServer} = await import("vite") + + process.on("SIGINT", onSignal) + process.on("SIGTERM", onSignal) + server = await createServer({ + mode: "development", + server: {host: "127.0.0.1", strictPort: true}, + }) + + if (!interrupted) { + await server.listen() + const url = new URL(server.resolvedUrls.local[0]) + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.href !== `${url.origin}/`) { + throw new Error("Desktop development requires an HTTP server on 127.0.0.1.") + } + + if (!interrupted) { + server.printUrls() + child = spawn( + process.execPath, + [ + // Capacitor 8.3.4 otherwise logs rejected platform hooks without failing the process. + "--unhandled-rejections=strict", + require.resolve("@capacitor/cli/bin/capacitor"), + "run", + "@capawesome/capacitor-electron", + ], + { + cwd: root, + stdio: "inherit", + detached: !windows, + env: {...process.env, FLOTILLA_DESKTOP_DEV_URL: url.href}, + }, + ) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", (code, signal) => resolve(signal ? 1 : code)) + }) + if (!interrupted) process.exitCode = code ?? 1 + } + } +} catch (error) { + if (!interrupted) { + console.error(error) + process.exitCode = 1 + } +} finally { + terminate("SIGKILL") + await server?.close() + clearTimeout(forceShutdown) + process.removeListener("SIGINT", onSignal) + process.removeListener("SIGTERM", onSignal) +}