Add desktop live reload and explicit local startup
Refs #51 Signed-off-by: Gaurav Chaudhary <chaudharygaurav2004@gmail.com>
This commit is contained in:
parent
19f5491024
commit
f8565aecb0
7 changed files with 228 additions and 12 deletions
32
README.md
32
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("; "),
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "install-electron",
|
||||
"start": "electron ."
|
||||
"start": "electron . --desktop-local"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^43.0.0",
|
||||
|
|
|
|||
|
|
@ -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
116
scripts/dev-desktop.mjs
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue