Added cross-platform desktop packaging (#526)

This commit is contained in:
Gaurav Chaudhary 2026-09-14 17:58:14 +00:00 committed by hodlbod
parent 3acc447a87
commit 8147d50c9f
14 changed files with 3696 additions and 25 deletions

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ vite.config.ts.timestamp-*
/playwright/.cache/ /playwright/.cache/
# Generated assets # Generated assets
static/desktop-logo.png
static/favicon.ico static/favicon.ico
static/pwa-64x64.png static/pwa-64x64.png
static/pwa-192x192.png static/pwa-192x192.png

View file

@ -47,8 +47,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
### Desktop development (Linux) ### Desktop development (Linux)
The Electron target is a development baseline. It has no supported installers, The Electron target and its unsigned packages are for development/testing.
release pipeline, packaging configuration, or auto-update setup. Release publishing and auto-updates are not configured.
**Use disposable accounts only.** The current secure-storage plugin falls back to **Use disposable accounts only.** The current secure-storage plugin falls back to
unencrypted `localStorage` on desktop. This is not secure desktop credential or unencrypted `localStorage` on desktop. This is not secure desktop credential or
@ -92,9 +92,49 @@ headless Linux runner, use `xvfb-run -a pnpm run test:desktop`; Electron links
against GTK, which Playwright's chromium dependencies do not cover, so such a box against GTK, which Playwright's chromium dependencies do not cover, so such a box
also needs `libgtk-3-0t64`. The test drops Chromium's sandbox when it runs as also needs `libgtk-3-0t64`. The test drops Chromium's sandbox when it runs as
root, because Chromium refuses to start that way. The separate smoke 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 suite does not start a web dev server. Windows and macOS desktop
behavior is not verified by the Linux test. CI does not run it. behavior is not verified by the Linux test. CI does not run it.
### Desktop packaging
After installing both sets of dependencies, run one of:
```sh
pnpm run package:desktop:linux
pnpm run package:desktop:windows
pnpm run package:desktop:macos
```
Each command rebuilds production assets, copies/updates Capacitor, compiles Electron,
vendors its runtime/plugins, and invokes electron-builder without publishing or signing.
Outputs are in `electron/dist/`: Linux x64 AppImage, Windows x64 NSIS installer,
and separate macOS x64/arm64 DMGs. The root package version is authoritative.
The Capacitor app ID remains stable; `VITE_PLATFORM_NAME` supplies the product name.
Vite's `.env.local` overrides also apply; use production branding values when
building artifacts for others. Explicit `VITE_*` environment values take precedence.
`VITE_PLATFORM_LOGO` can be a local image or HTTPS image. Packaging resizes it
to 1024×1024 and stages it in ignored output.
Linux packaging requires Linux. Windows packaging from Linux uses the pinned
official `electronuserland/builder` Wine image through Docker; it only mounts a
temporary copy of the prepared Electron project. Native addons require a target-OS
ABI rebuild and cannot use this cross-build path. Native Windows preparation needs
Bash on PATH (for example, Git Bash). DMG creation requires macOS; on Linux,
`pnpm run package:desktop:macos --dir` prepares unsigned bundles for inspection only.
It does not verify macOS runtime, Gatekeeper, or signing.
To smoke-test a package, run as a non-root user with the sandbox enabled:
```sh
FLOTILLA_DESKTOP_EXECUTABLE="/absolute/path/to/application" pnpm run test:desktop
```
Use the AppImage or installed executable, rather than the installer. This checks
packaged metadata, local assets, navigation, workers and CSP using a disposable
profile. Installation, reboot and uninstall still require target-OS testing.
Packages must remain development-only until OS-protected secret storage and release
signing are addressed.
## Deployment ## Deployment
To run your own Flotilla, it's as simple as: To run your own Flotilla, it's as simple as:

View file

@ -1,8 +1,9 @@
import type {CapacitorConfig} from "@capacitor/cli" import type {CapacitorConfig} from "@capacitor/cli"
import {loadEnv} from "vite"
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: "social.flotilla", appId: "social.flotilla",
appName: "Flotilla", appName: loadEnv(process.env.NODE_ENV || "production", process.cwd(), "VITE_").VITE_PLATFORM_NAME,
webDir: "build", webDir: "build",
ios: { ios: {
scheme: "Flotilla Chat", scheme: "Flotilla Chat",

View file

@ -1,25 +1,25 @@
import {mkdtemp, rm} from "node:fs/promises" import {mkdtemp, readFile, rm} from "node:fs/promises"
import {createRequire} from "node:module" import {createRequire} from "node:module"
import {tmpdir} from "node:os" import {tmpdir} from "node:os"
import {join, resolve} from "node:path" import {join, resolve} from "node:path"
import {_electron, expect, test} from "@playwright/test" import {_electron, expect, test} from "@playwright/test"
test("the desktop baseline renders, navigates, and keeps external pages outside", async () => { test("the desktop app renders, navigates, and keeps external pages outside", async () => {
const profile = await mkdtemp(join(tmpdir(), "flotilla-desktop-")) const profile = await mkdtemp(join(tmpdir(), "flotilla-desktop-"))
try { try {
const executablePath: string = createRequire(import.meta.url)( const packaged = process.env.FLOTILLA_DESKTOP_EXECUTABLE
resolve("electron/node_modules/electron"), const executablePath: string =
) packaged || createRequire(import.meta.url)(resolve("electron/node_modules/electron"))
const app = await _electron.launch({ const app = await _electron.launch({
executablePath, executablePath,
// Chromium refuses to start as root with its sandbox on, which is what a CI container is. // Chromium refuses to start as root with its sandbox on, which is what a CI container is.
chromiumSandbox: process.getuid?.() !== 0, chromiumSandbox: packaged ? true : process.getuid?.() !== 0,
args: [resolve("electron")], args: [...(packaged ? [] : [resolve("electron")]), `--user-data-dir=${profile}`],
env: {...process.env, XDG_CONFIG_HOME: profile},
}) })
try { try {
expect(await app.evaluate(({app}) => app.getPath("userData"))).toBe(profile)
const mainWindows = () => const mainWindows = () =>
app.windows().filter(page => page.url().startsWith("capacitor-electron://")) app.windows().filter(page => page.url().startsWith("capacitor-electron://"))
await expect.poll(() => mainWindows().length).toBe(1) await expect.poll(() => mainWindows().length).toBe(1)
@ -38,6 +38,25 @@ test("the desktop baseline renders, navigates, and keeps external pages outside"
).toBe(await page.locator("body").getAttribute("data-theme")) ).toBe(await page.locator("body").getAttribute("data-theme"))
const origin = await page.evaluate(() => location.origin) const origin = await page.evaluate(() => location.origin)
expect(origin).toMatch(/^capacitor-electron:\/\//) expect(origin).toMatch(/^capacitor-electron:\/\//)
expect(await page.locator('script[src*="@vite/client"]').count()).toBe(0)
if (packaged) {
const {version} = JSON.parse(await readFile("package.json", "utf8"))
expect(await app.evaluate(({app}) => app.isPackaged)).toBe(true)
expect(await app.evaluate(({app}) => app.getName())).toBe(await page.title())
expect(await app.evaluate(({app}) => app.getVersion())).toBe(version)
expect(await app.evaluate(({app}) => app.getAppPath())).toMatch(/app\.asar$/)
expect(await app.evaluate(({app}) => app.commandLine.hasSwitch("no-sandbox"))).toBe(false)
const preferences = await app.browserWindow(page).then(window =>
window.evaluate(window => {
const {sandbox, contextIsolation, nodeIntegration} =
window.webContents.getLastWebPreferences()
return {sandbox, contextIsolation, nodeIntegration}
}),
)
expect(preferences).toEqual({sandbox: true, contextIsolation: true, nodeIntegration: false})
await page.screenshot({path: test.info().outputPath("packaged-onboarding.png")})
}
await page.getByRole("button", {name: "Log in", exact: true}).click() await page.getByRole("button", {name: "Log in", exact: true}).click()
await expect(page.getByTestId("login")).toBeVisible() await expect(page.getByTestId("login")).toBeVisible()

View file

@ -0,0 +1,49 @@
import {readFileSync} from "node:fs"
const {name, version, author} = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
)
const {appId, appName} = JSON.parse(
readFileSync(new URL("generated/capacitor.config.json", import.meta.url), "utf8"),
)
/** @type {import('electron-builder').Configuration} */
export default {
appId,
productName: appName,
executableName: name,
buildVersion: version,
extraMetadata: {
name,
version,
author,
productName: appName,
description: appName,
desktopName: `${appId}.desktop`,
},
artifactName: "${productName}-${version}-${os}-${arch}.${ext}",
directories: {output: "dist", buildResources: "generated"},
icon: "generated/icon.png",
files: [
"build/main.js",
"app/**/*",
"generated/**/*",
"package.json",
{from: "vendor/node_modules", to: "node_modules"},
],
allowMissingDependencies: false,
publish: [],
toolsets: {appimage: "1.0.3"},
linux: {
target: [{target: "AppImage", arch: ["x64"]}],
category: "Network",
syncDesktopName: true,
},
win: {target: [{target: "nsis", arch: ["x64"]}], signExecutable: false},
mac: {
target: [{target: "dmg", arch: ["x64", "arm64"]}],
category: "public.app-category.social-networking",
notarize: false,
},
dmg: {sign: false},
}

View file

@ -1,7 +1,16 @@
import {join} from "node:path"
import {app} from "electron"
import {createCapacitorElectronApp} from "@capawesome/capacitor-electron" import {createCapacitorElectronApp} from "@capawesome/capacitor-electron"
createCapacitorElectronApp({ createCapacitorElectronApp({
window: {width: 1200, height: 800}, window: {width: 1200, height: 800},
hooks: {
onWindowCreated: window => {
if (process.platform === "linux" && app.isPackaged) {
window.setIcon(join(app.getAppPath(), "generated/icon.png"))
}
},
},
csp: { csp: {
// SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap. // SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap.
policy: "base-uri 'self'; object-src 'none'", policy: "base-uri 'self'; object-src 'none'",

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,17 @@
{ {
"name": "flotilla-electron", "name": "flotilla-electron",
"private": true, "private": true,
"version": "0.0.0",
"description": "Electron platform project for Flotilla.", "description": "Electron platform project for Flotilla.",
"main": "build/main.js", "main": "build/main.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"postinstall": "install-electron", "postinstall": "install-electron",
"start": "electron ." "start": "electron .",
"pack": "electron-builder --config electron-builder.config.mjs --publish never"
}, },
"devDependencies": { "devDependencies": {
"electron": "^43.0.0", "electron": "^43.0.0",
"electron-builder": "26.16.1",
"typescript": "^5.5.0" "typescript": "^5.5.0"
} }
} }

View file

@ -1,6 +1,7 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.9.1", "version": "1.9.1",
"author": {"name": "Jon Staab"},
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -14,13 +15,16 @@
"bump": "node scripts/bump-version.mjs", "bump": "node scripts/bump-version.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"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", "lint": "prettier --check src e2e playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs && eslint src e2e capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs",
"test": "playwright test", "test": "playwright test",
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
"test:ui": "playwright test --ui", "test:ui": "playwright test --ui",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src", "format:all": "prettier --write src",
"prepare": "husky" "prepare": "husky",
"package:desktop:linux": "node scripts/package-desktop.mjs linux",
"package:desktop:windows": "node scripts/package-desktop.mjs windows",
"package:desktop:macos": "node scripts/package-desktop.mjs macos"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",

View file

@ -2,7 +2,13 @@
set -e set -e
export FLOTILLA_DESKTOP=1 export FLOTILLA_DESKTOP=1
export NODE_ENV=production
unset FLOTILLA_DESKTOP_DEV_URL CAPACITOR_ELECTRON_DEV_SERVER_URL
source ./scripts/build-web.sh source ./scripts/build-web.sh
pnpm exec cap sync @capawesome/capacitor-electron export VITE_PLATFORM_NAME
# Capacitor sync swallows copy failures; its rejection handler also leaves exit 0.
node --unhandled-rejections=strict node_modules/@capacitor/cli/bin/capacitor copy @capawesome/capacitor-electron
node --unhandled-rejections=strict node_modules/@capacitor/cli/bin/capacitor update @capawesome/capacitor-electron
rm -rf electron/build
npm --prefix electron run build npm --prefix electron run build

View file

@ -33,4 +33,3 @@ perl -i -pe"s|{DESCRIPTION}|$VITE_PLATFORM_DESCRIPTION|g" build/index.html
perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html
perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html
perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html

122
scripts/package-desktop.mjs Normal file
View file

@ -0,0 +1,122 @@
import {spawn} from "node:child_process"
import {cp, mkdir, mkdtemp, readFile, readdir, rm} from "node:fs/promises"
import {join, resolve} from "node:path"
import {fileURLToPath} from "node:url"
import sharp from "sharp"
import {loadEnv} from "vite"
const root = fileURLToPath(new URL("../", import.meta.url))
const [target, option, ...rest] = process.argv.slice(2)
const platforms = {linux: "--linux", windows: "--win", macos: "--mac"}
const env = {...process.env, ...loadEnv("production", root, "VITE_"), NODE_ENV: "production"}
const run = (command, args, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {cwd: root, env, stdio: "inherit", ...options})
child.on("error", reject)
child.on("exit", (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`${command} failed (${signal || code})`))
})
})
try {
if (!Object.hasOwn(platforms, target) || rest.length || (option && option !== "--dir")) {
throw new Error("Usage: node scripts/package-desktop.mjs linux|windows|macos [--dir]")
}
delete env.FLOTILLA_DESKTOP_DEV_URL
delete env.CAPACITOR_ELECTRON_DEV_SERVER_URL
env.CSC_IDENTITY_AUTO_DISCOVERY = "false"
let logo
if (env.VITE_PLATFORM_LOGO?.startsWith("https://")) {
const response = await fetch(env.VITE_PLATFORM_LOGO, {signal: AbortSignal.timeout(30_000)})
if (!response.ok || !response.headers.get("content-type")?.startsWith("image/")) {
throw new Error("VITE_PLATFORM_LOGO must return a successful image response")
}
logo = Buffer.from(await response.arrayBuffer())
} else {
logo = await readFile(resolve(root, env.VITE_PLATFORM_LOGO))
}
env.VITE_PLATFORM_LOGO = "static/desktop-logo.png"
await sharp(logo).resize(1024, 1024).png().toFile(join(root, env.VITE_PLATFORM_LOGO))
await run("bash", ["scripts/build-desktop.sh"])
await cp(join(root, env.VITE_PLATFORM_LOGO), join(root, "electron/generated/icon.png"))
await cp(join(root, "LICENSE"), join(root, "electron/generated/LICENSE"))
await run(
process.execPath,
[join(root, "node_modules/@capawesome/capacitor-electron/dist/cli/index.js"), "vendor"],
{cwd: join(root, "electron")},
)
const args = [platforms[target], ...(option ? [option] : [])]
if (target === "macos" && option === "--dir") {
// --dir replaces configured targets, including their architectures.
args.push("--x64", "--arm64")
}
if (target === "windows" && process.platform === "linux") {
const files = await readdir(join(root, "electron/vendor"), {recursive: true})
if (files.some(file => file.endsWith(".node"))) {
throw new Error(
"Native Electron addons require a Windows ABI rebuild before packaging on Windows",
)
}
await mkdir(join(root, "electron/dist"), {recursive: true})
const directory = await mkdtemp(join(root, "electron/dist/package-"))
try {
await cp(join(root, "package.json"), join(directory, "package.json"))
for (const file of [
"package.json",
"package-lock.json",
"electron-builder.config.mjs",
"build",
"app",
"generated",
"vendor",
]) {
await cp(join(root, "electron", file), join(directory, "electron", file), {recursive: true})
}
await run("docker", [
"run",
"--rm",
"--platform",
"linux/amd64",
"--volume",
`${directory}:/project:Z`,
"--workdir",
"/project/electron",
"--env",
"USE_SYSTEM_WINE=true",
"--env",
"CSC_IDENTITY_AUTO_DISCOVERY=false",
"electronuserland/builder@sha256:41ae540902461b6cbc988987db79547fcc10cda04d2a6c6367504f59d4b37c64",
"bash",
"-c",
'owner=$1; group=$2; shift 2; npm ci --ignore-scripts && npm run pack -- "$@"; result=$?; chown -R "$owner:$group" /project; exit "$result"',
"--",
String(process.getuid()),
String(process.getgid()),
...args,
])
await cp(join(directory, "electron/dist"), join(root, "electron/dist"), {recursive: true})
} finally {
await rm(directory, {recursive: true, force: true})
}
} else {
await run(
process.execPath,
[
"node_modules/electron-builder/cli.js",
"--config",
"electron-builder.config.mjs",
"--publish",
"never",
...args,
],
{cwd: join(root, "electron")},
)
}
} finally {
await rm(join(root, "static/desktop-logo.png"), {force: true})
}

View file

@ -44,9 +44,7 @@ export const PLATFORM_TERMS = env("VITE_PLATFORM_TERMS")
export const PLATFORM_PRIVACY = env("VITE_PLATFORM_PRIVACY") export const PLATFORM_PRIVACY = env("VITE_PLATFORM_PRIVACY")
export const PLATFORM_LOGO = import.meta.env.PROD export const PLATFORM_LOGO = env("VITE_PLATFORM_LOGO").replace(/^static/, "")
? PLATFORM_URL + "/logo.png"
: env("VITE_PLATFORM_LOGO").replace(/^static/, "") || PLATFORM_URL + "/logo.png"
export const PLATFORM_NAME = env("VITE_PLATFORM_NAME") export const PLATFORM_NAME = env("VITE_PLATFORM_NAME")

View file

@ -14,7 +14,7 @@ import {
import type {Nip46ResponseWithResult} from "@welshman/signer" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
import {makeSecret} from "@welshman/util" import {makeSecret} from "@welshman/util"
import {PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/env" import {PLATFORM_URL, PLATFORM_NAME, SIGNER_RELAYS} from "@app/env"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
const APP_SCHEME = "social.flotilla" const APP_SCHEME = "social.flotilla"
@ -67,7 +67,7 @@ export class Nip46Controller {
const url = await this.broker.makeNostrconnectUrl({ const url = await this.broker.makeNostrconnectUrl({
url: PLATFORM_URL, url: PLATFORM_URL,
name: PLATFORM_NAME, name: PLATFORM_NAME,
image: PLATFORM_LOGO, image: PLATFORM_URL + "/logo.png",
perms: NIP46_PERMS, perms: NIP46_PERMS,
}) })