diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5d0738a8..5ef6fde2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,6 +31,11 @@ jobs: - name: Check run: pnpm run check + - name: Check desktop TypeScript + run: | + npm ci --prefix electron --ignore-scripts + npm --prefix electron run build + - name: Build if: github.event_name != 'pull_request' run: pnpm run build @@ -76,3 +81,27 @@ jobs: path: build retention-days: 7 if-no-files-found: error + + desktop-smoke: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: | + corepack enable && pnpm i --frozen-lockfile + npm ci --prefix electron + pnpm exec playwright install-deps chromium + + - name: Build desktop + run: pnpm run build:desktop + + - name: Test desktop + run: xvfb-run -a pnpm run test:desktop diff --git a/.gitignore b/.gitignore index aeac9c62..dbcecf43 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ CLAUDE.md .DS_Store Thumbs.db package-lock.json +!electron/package-lock.json diff --git a/README.md b/README.md index ac843917..74ff91f4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,38 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib See [CONTRIBUTING.md](CONTRIBUTING.md). +### Desktop development (Linux) + +The Electron target is a development baseline. It has no supported installers, +release pipeline, packaging configuration, or auto-update setup. + +**Use disposable accounts only.** The current secure-storage plugin falls back to +unencrypted `localStorage` on desktop. This is not secure desktop credential or +private-key storage. OS-protected secret storage is required before distribution. + +Install the root dependencies with pnpm and the Electron subproject with npm, +following the platform's documented setup. Installing that subproject separately avoids +downloading Electron for ordinary web/mobile installs: + +```sh +pnpm install --frozen-lockfile +npm ci --prefix electron +pnpm run build:desktop +pnpm run dev: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. + +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`. 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 builds and runs this suite under +Xvfb in a separate desktop job. + ## Deployment To run your own Flotilla, it's as simple as: diff --git a/e2e/desktop/playwright.config.ts b/e2e/desktop/playwright.config.ts new file mode 100644 index 00000000..d5795e87 --- /dev/null +++ b/e2e/desktop/playwright.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from "@playwright/test" + +export default defineConfig({ + testDir: ".", + forbidOnly: !!process.env.CI, + timeout: 60_000, + expect: {timeout: 15_000}, + workers: 1, + reporter: "list", +}) diff --git a/e2e/desktop/smoke.spec.ts b/e2e/desktop/smoke.spec.ts new file mode 100644 index 00000000..75139012 --- /dev/null +++ b/e2e/desktop/smoke.spec.ts @@ -0,0 +1,101 @@ +import {mkdtemp, rm} from "node:fs/promises" +import {createRequire} from "node:module" +import {tmpdir} from "node:os" +import {join, resolve} from "node:path" +import {_electron, expect, test} from "@playwright/test" + +test("the desktop baseline renders, navigates, and keeps external pages outside", async () => { + const profile = await mkdtemp(join(tmpdir(), "flotilla-desktop-")) + + try { + const executablePath: string = createRequire(import.meta.url)( + resolve("electron/node_modules/electron"), + ) + const app = await _electron.launch({ + executablePath, + chromiumSandbox: true, + args: [resolve("electron")], + env: {...process.env, XDG_CONFIG_HOME: profile}, + }) + + try { + const mainWindows = () => + app.windows().filter(page => page.url().startsWith("capacitor-electron://")) + await expect.poll(() => mainWindows().length).toBe(1) + const [page] = mainWindows() + const errors: string[] = [] + + page.on("pageerror", error => errors.push(error.message)) + + await page.reload() + await expect(page.getByRole("heading")).toBeVisible() + const origin = await page.evaluate(() => location.origin) + expect(origin).toMatch(/^capacitor-electron:\/\//) + + await page.getByRole("button", {name: "Log in", exact: true}).click() + await expect(page.getByTestId("login")).toBeVisible() + + await page.goto(`${origin}/settings/profile`) + await expect(page.getByRole("heading")).toBeVisible() + await page.reload() + await expect(page.getByRole("heading")).toBeVisible() + expect(new URL(page.url()).pathname).toBe("/settings/profile") + + expect( + await page.evaluate(async () => { + const url = URL.createObjectURL( + new Blob(["onmessage = event => postMessage(event.data)"], {type: "text/javascript"}), + ) + const worker = new Worker(url) + + try { + return await new Promise((resolve, reject) => { + worker.onmessage = event => resolve(event.data) + worker.onerror = event => reject(new Error(event.message)) + worker.postMessage("desktop worker ready") + }) + } finally { + worker.terminate() + URL.revokeObjectURL(url) + } + }), + ).toBe("desktop worker ready") + + const externalUrls = await app.evaluateHandle(({shell}) => { + const urls: string[] = [] + shell.openExternal = async (url: string) => { + urls.push(url) + } + return urls + }) + const externalLink = page.locator('a[target="_blank"][href^="https:"]').first() + const externalUrl = await externalLink.evaluate((link: HTMLAnchorElement) => link.href) + await externalLink.click() + await expect.poll(() => externalUrls.jsonValue()).toContain(externalUrl) + expect(await page.evaluate(() => location.origin)).toBe(origin) + expect(app.windows()).toHaveLength(1) + + expect(errors).toEqual([]) + + await page.evaluate(() => { + document.addEventListener( + "securitypolicyviolation", + ({effectiveDirective}) => { + document.documentElement.dataset.cspViolation = effectiveDirective + }, + {once: true}, + ) + const script = document.createElement("script") + script.textContent = "document.documentElement.dataset.inlineScriptExecuted = 'true'" + document.head.appendChild(script) + script.remove() + }) + await expect(page.locator("html")).toHaveAttribute("data-csp-violation", "script-src-elem") + await expect(page.locator("html")).not.toHaveAttribute("data-inline-script-executed") + } finally { + await app.close() + } + } finally { + await rm(profile, {recursive: true, force: true}) + } +}) diff --git a/electron/.gitignore b/electron/.gitignore new file mode 100644 index 00000000..34494b8f --- /dev/null +++ b/electron/.gitignore @@ -0,0 +1,6 @@ +node_modules +build +dist +app +generated +vendor diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 00000000..5e97b1d9 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,7 @@ +import {createCapacitorElectronApp} from "@capawesome/capacitor-electron" + +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'"}, +}) diff --git a/electron/package-lock.json b/electron/package-lock.json new file mode 100644 index 00000000..24a94440 --- /dev/null +++ b/electron/package-lock.json @@ -0,0 +1,189 @@ +{ + "name": "flotilla-electron", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flotilla-electron", + "version": "0.0.0", + "devDependencies": { + "electron": "^43.0.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron": { + "version": "43.6.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.6.0.tgz", + "integrity": "sha512-DqVKYV+FXheMSLTxcMQ+NCo78BDgpnToSyIzXctlUtbP3lRGEuoo1P+C2n/90rJ7TvHgzP0bpP9fbbXxp4noIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 00000000..f961a197 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,15 @@ +{ + "name": "flotilla-electron", + "private": true, + "version": "0.0.0", + "description": "Electron platform project for Flotilla.", + "main": "build/main.js", + "scripts": { + "build": "tsc", + "start": "electron ." + }, + "devDependencies": { + "electron": "^43.0.0", + "typescript": "^5.5.0" + } +} diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 00000000..ff4f945b --- /dev/null +++ b/electron/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "node16", + "moduleResolution": "node16", + "outDir": "build", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": [ + "main.ts" + ] +} diff --git a/package.json b/package.json index d4c7e618..647910f2 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "scripts": { "dev": "vite dev", "build": "./scripts/build.sh", + "build:desktop": "bash scripts/build-desktop.sh", + "dev: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 && eslint src e2e", + "lint": "prettier --check src e2e playwright.config.ts electron/*.ts && eslint src e2e electron/*.ts", "test": "playwright test", + "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "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:all": "prettier --write src", @@ -63,6 +66,7 @@ "@capacitor/share": "^8.0.1", "@capawesome/capacitor-android-dark-mode-support": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0", + "@capawesome/capacitor-electron": "0.1.1", "@getalby/lightning-tools": "^6.1.0", "@getalby/sdk": "^5.1.2", "@hono/node-server": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd97887a..a76e1aad 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh new file mode 100644 index 00000000..06c5d534 --- /dev/null +++ b/scripts/build-desktop.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +export FLOTILLA_DESKTOP=1 +source ./scripts/build-web.sh + +pnpm exec cap sync @capawesome/capacitor-electron +npm --prefix electron run build diff --git a/scripts/build-web.sh b/scripts/build-web.sh new file mode 100644 index 00000000..4ec8e18d --- /dev/null +++ b/scripts/build-web.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +temp_env=$(declare -p -x) + +if [ -f .env ]; then + source .env +fi + +# Avoid overwriting env vars provided directly +# https://stackoverflow.com/a/69127685/1467342 +eval "$temp_env" + +if [[ -z $VITE_BUILD_HASH ]]; then + export VITE_BUILD_HASH=$(git rev-parse --short HEAD) +fi + +if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then + curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png + export VITE_PLATFORM_LOGO=static/logo.png + + # @capacitor/assets reads its source logo from assets/, not static/, so + # propagate a custom platform logo there too for the native app icon/splash + cp "$VITE_PLATFORM_LOGO" assets/logo.png + cp "$VITE_PLATFORM_LOGO" assets/logo-dark.png +fi + +# Ensure generator uses local path (dotenv may have loaded URL from .env) +VITE_PLATFORM_LOGO="${VITE_PLATFORM_LOGO}" npx pwa-assets-generator +npx vite build + +# Replace index.html variables with stuff from our env +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|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html +perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html + diff --git a/scripts/build.sh b/scripts/build.sh index 3a8b6d5e..3a4696b1 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,38 +1,6 @@ #!/usr/bin/env bash -temp_env=$(declare -p -x) - -if [ -f .env ]; then - source .env -fi - -# Avoid overwriting env vars provided directly -# https://stackoverflow.com/a/69127685/1467342 -eval "$temp_env" - -if [[ -z $VITE_BUILD_HASH ]]; then - export VITE_BUILD_HASH=$(git rev-parse --short HEAD) -fi - -if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then - curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png - export VITE_PLATFORM_LOGO=static/logo.png - - # @capacitor/assets reads its source logo from assets/, not static/, so - # propagate a custom platform logo there too for the native app icon/splash - cp "$VITE_PLATFORM_LOGO" assets/logo.png - cp "$VITE_PLATFORM_LOGO" assets/logo-dark.png -fi - -# Ensure generator uses local path (dotenv may have loaded URL from .env) -VITE_PLATFORM_LOGO="${VITE_PLATFORM_LOGO}" npx pwa-assets-generator -npx vite build - -# Replace index.html variables with stuff from our env -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|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html -perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html +source ./scripts/build-web.sh npx cap sync npx @capacitor/assets generate \ diff --git a/src/app/components/Landing.svelte b/src/app/components/Landing.svelte index 0c738732..9c8697a3 100644 --- a/src/app/components/Landing.svelte +++ b/src/app/components/Landing.svelte @@ -29,7 +29,7 @@

Welcome to {PLATFORM_NAME}!

{PLATFORM_DESCRIPTION}

-