Add desktop live reload and explicit local startup

Refs #51

Signed-off-by: Gaurav Chaudhary <chaudharygaurav2004@gmail.com>
This commit is contained in:
Gaurav Chaudhary 2026-09-11 17:23:30 +05:30
parent 19f5491024
commit f8565aecb0
7 changed files with 228 additions and 12 deletions

View file

@ -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:

View file

@ -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

View file

@ -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})
}

View file

@ -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("; "),
},
})

View file

@ -7,7 +7,7 @@
"scripts": {
"build": "tsc",
"postinstall": "install-electron",
"start": "electron ."
"start": "electron . --desktop-local"
},
"devDependencies": {
"electron": "^43.0.0",

View file

@ -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",

116
scripts/dev-desktop.mjs Normal file
View file

@ -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)
}