From cfeae50ca55331f3636c56546640476bfaf68004 Mon Sep 17 00:00:00 2001 From: Haidong Xin Date: Tue, 29 Sep 2026 09:50:25 +0800 Subject: [PATCH 1/2] feat(desktop): add Ubuntu DEB support and Linux desktop chrome --- .github/workflows/desktop-build.yml | 7 + .github/workflows/desktop-linux.yml | 147 +++++++++++++++ .github/workflows/desktop-smoke.yml | 8 +- .github/workflows/release.yml | 4 + .gitignore | 1 + apps/desktop/README.md | 71 ++++++-- apps/desktop/package.json | 28 +++ apps/desktop/scripts/after-pack.cjs | 22 +++ .../scripts/check-linux-build-host.mjs | 13 ++ .../scripts/create-release-manifest.mjs | 1 + .../scripts/create-release-manifest.test.mjs | 8 + .../scripts/desktop-lifecycle.test.mjs | 34 +++- .../scripts/fixtures/desktop-chrome.cjs | 36 +++- .../desktop/scripts/run-linux-chrome-smoke.sh | 70 ++++++++ .../scripts/run-linux-display-smoke.sh | 47 +++++ apps/desktop/scripts/verify-linux-boot.cjs | 99 +++++++++++ apps/desktop/scripts/verify-linux-package.cjs | 63 +++++++ .../desktop/scripts/verify-update-network.cjs | 7 +- apps/desktop/scripts/verify-updater.cjs | 1 + apps/desktop/src/desktopTray.ts | 4 +- apps/desktop/src/linuxCaptionMenu.ts | 37 ++++ apps/desktop/src/linuxCaptionPopup.ts | 158 ++++++++++++++++ apps/desktop/src/main.ts | 74 ++++++-- apps/desktop/src/preloadChrome.ts | 40 +++-- apps/desktop/src/updates.ts | 11 +- apps/desktop/src/windowChrome.ts | 4 +- docs/release.md | 39 +++- docs/ubuntu-desktop-support.md | 168 ++++++++++++++++++ ui/e2e/desktop-chrome.smoke.mjs | 115 ++++++++++-- ui/server/services/desktopChrome.test.ts | 21 ++- ui/server/services/desktopUpdates.spec.ts | 84 +++++++++ ui/src/components/desktop/desktop.css | 8 +- 32 files changed, 1343 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/desktop-linux.yml create mode 100644 apps/desktop/scripts/check-linux-build-host.mjs create mode 100644 apps/desktop/scripts/run-linux-chrome-smoke.sh create mode 100644 apps/desktop/scripts/run-linux-display-smoke.sh create mode 100644 apps/desktop/scripts/verify-linux-boot.cjs create mode 100644 apps/desktop/scripts/verify-linux-package.cjs create mode 100644 apps/desktop/src/linuxCaptionMenu.ts create mode 100644 apps/desktop/src/linuxCaptionPopup.ts create mode 100644 docs/ubuntu-desktop-support.md create mode 100644 ui/server/services/desktopUpdates.spec.ts diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 955ffaf77..5290fccdf 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -59,6 +59,13 @@ env: PILOTDECK_UPDATE_REPOSITORY: ${{ github.repository }} jobs: + linux: + uses: ./.github/workflows/desktop-linux.yml + with: + source_sha: ${{ inputs.source_sha || github.sha }} + release_date: ${{ inputs.release_date }} + revision: ${{ inputs.revision || '0' }} + macos: name: macOS ${{ matrix.arch }} strategy: diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml new file mode 100644 index 000000000..2062369cd --- /dev/null +++ b/.github/workflows/desktop-linux.yml @@ -0,0 +1,147 @@ +name: Ubuntu DEB Smoke + +on: + workflow_call: + inputs: + source_sha: + description: Exact source commit to build + required: true + type: string + release_date: + description: Optional release date in YYYY-MM-DD + required: false + default: "" + type: string + revision: + description: Zero-based same-day build revision + required: false + default: "0" + type: string + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PILOTDECK_UPDATE_REPOSITORY: ${{ github.repository }} + +jobs: + build-and-smoke: + name: Ubuntu 22.04 ${{ matrix.arch }} build and smoke + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + deb_arch: amd64 + unpacked_dir: linux-unpacked + runner: ubuntu-22.04 + - arch: arm64 + deb_arch: arm64 + unpacked_dir: linux-arm64-unpacked + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + env: + PILOTDECK_RELEASE_DATE: ${{ inputs.release_date }} + PILOTDECK_RELEASE_REVISION: ${{ inputs.revision }} + PILOTDECK_COMMIT_SHA: ${{ inputs.source_sha || github.sha }} + PILOTDECK_DESKTOP_NODE_ARCH: ${{ matrix.arch }} + NODE_OPTIONS: --max-old-space-size=2560 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_sha || github.sha }} + + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 + + - uses: actions/setup-node@v7 + with: + node-version: 22.23.1 + cache: pnpm + + - name: Verify native Ubuntu 22.04 build host + run: node apps/desktop/scripts/check-linux-build-host.mjs ${{ matrix.arch }} + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Verify Linux background and quit lifecycle + run: node --test apps/desktop/scripts/desktop-lifecycle.test.mjs + + - name: Set desktop release metadata + run: node apps/desktop/scripts/set-ci-version.mjs + + - name: Build Ubuntu ${{ matrix.arch }} DEB + run: pnpm --filter pilotdeck-desktop run dist:linux:${{ matrix.arch }} + + - name: Verify Ubuntu artifact and updater + shell: bash + env: + EXPECTED_DEB_ARCH: ${{ matrix.deb_arch }} + EXPECTED_ARCH: ${{ matrix.arch }} + UNPACKED_DIR: ${{ matrix.unpacked_dir }} + run: | + set -euo pipefail + deb_path="$(find apps/desktop/dist-electron -maxdepth 1 -name "*-linux-${EXPECTED_DEB_ARCH}.deb" -print -quit)" + test -n "$deb_path" + test "$(dpkg-deb --field "$deb_path" Architecture)" = "$EXPECTED_DEB_ARCH" + feed="latest-linux.yml" + if [ "$EXPECTED_ARCH" = arm64 ]; then feed="latest-linux-arm64.yml"; fi + test -s "apps/desktop/dist-electron/$feed" + grep -Fq "$(basename "$deb_path")" "apps/desktop/dist-electron/$feed" + app_dir="apps/desktop/dist-electron/$UNPACKED_DIR" + test -f "$app_dir/resources/package-type" + test "$(cat "$app_dir/resources/package-type")" = deb + ELECTRON_RUN_AS_NODE=1 "$app_dir/pilotdeck-desktop" \ + apps/desktop/scripts/verify-updater.cjs "$app_dir/resources/app.asar" + echo "DEB_PATH=$deb_path" >> "$GITHUB_ENV" + + - name: Install headless display and launcher validation tools + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ + dbus-x11 desktop-file-utils weston x11-utils xauth xvfb + + - name: Install DEB and exercise bundled native modules + env: + EXPECTED_ARCH: ${{ matrix.arch }} + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "./$DEB_PATH" + desktop-file-validate /usr/share/applications/pilotdeck-desktop.desktop + /opt/PilotDeck/resources/node/bin/node \ + apps/desktop/scripts/verify-linux-package.cjs "$EXPECTED_ARCH" + + - name: Smoke installed app under X11 + run: bash apps/desktop/scripts/run-linux-display-smoke.sh x11 + + - name: Smoke installed app under Wayland + run: bash apps/desktop/scripts/run-linux-display-smoke.sh wayland + + - name: Verify Linux menus, language and theme under X11 + run: bash apps/desktop/scripts/run-linux-chrome-smoke.sh x11 + + - name: Verify Linux menus, language and theme under Wayland + run: bash apps/desktop/scripts/run-linux-chrome-smoke.sh wayland + + - name: Upload Linux chrome screenshots + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: pilotdeck-linux-chrome-${{ matrix.arch }} + path: outputs/desktop-chrome-review/linux-*/linux-*.png + if-no-files-found: ignore + retention-days: 14 + + - name: Upload Ubuntu installer + uses: actions/upload-artifact@v7 + with: + name: pilotdeck-desktop-ubuntu-${{ matrix.arch }} + overwrite: true + path: | + apps/desktop/dist-electron/*-linux-${{ matrix.deb_arch }}.deb + apps/desktop/dist-electron/latest-linux*.yml + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/desktop-smoke.yml b/.github/workflows/desktop-smoke.yml index 4682cc225..68a9819b5 100644 --- a/.github/workflows/desktop-smoke.yml +++ b/.github/workflows/desktop-smoke.yml @@ -48,7 +48,13 @@ jobs: - name: Test desktop updater network # Requires electron-updater, intentionally absent from the Web-only job. - run: pnpm --dir ui exec vitest run server/services/desktopUpdateNetwork.test.ts + run: pnpm --dir ui exec vitest run server/services/desktopUpdateNetwork.test.ts server/services/desktopUpdates.spec.ts + + # Use the same native Ubuntu package and display checks before merge and release. + ubuntu-deb: + uses: ./.github/workflows/desktop-linux.yml + with: + source_sha: ${{ github.sha }} # Run the same installer build and artifact/feed validation as Daily Release. # A process-only check cannot catch NSIS compilation or packaging regressions. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 887c57c42..6bda682ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,9 +115,13 @@ jobs: find release-assets -maxdepth 1 -name '*-setup.exe' -print -quit | grep -q . find release-assets -maxdepth 1 -name '*-mac-arm64.zip' -print -quit | grep -q . find release-assets -maxdepth 1 -name '*-mac-x64.zip' -print -quit | grep -q . + find release-assets -maxdepth 1 -name '*-linux-arm64.deb' -print -quit | grep -q . + find release-assets -maxdepth 1 -name '*-linux-amd64.deb' -print -quit | grep -q . test -s release-assets/latest-arm64-mac.yml test -s release-assets/latest-x64-mac.yml test -s release-assets/latest-x64.yml + test -s release-assets/latest-linux.yml + test -s release-assets/latest-linux-arm64.yml test -s release-assets/release.json test -s release-assets/SHA256SUMS.txt diff --git a/.gitignore b/.gitignore index bd484be79..1a63d2d09 100644 --- a/.gitignore +++ b/.gitignore @@ -146,6 +146,7 @@ vite.config.ts.timestamp-* # Local recording / artifacts artifacts/ +outputs/desktop-chrome-review/ !src/session/artifacts/ !src/session/artifacts/** diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 27b2d9be9..a297c6c77 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -43,7 +43,18 @@ The tray's **Quit** command and **File > Exit** restore the main window and show an owned confirmation dialog. Cancel is the default. Confirming stops the managed runtime before the application exits; automatic updates use their existing quit path without a second confirmation. Tray menus and confirmation text follow the -application language. Linux retains its existing close behavior. +application language. + +### Ubuntu + +Closing the main window keeps the app and local tasks running when a system tray +is available. The PilotDeck icon in Ubuntu's top panel opens a native menu with +**Open main window** and **Quit**. Select **Open main window** to restore it. +Quit asks for confirmation and stops managed tasks. Launching PilotDeck again +restores the existing window instead of starting another runtime. If creating +the tray icon fails, closing the window quits normally so it is not stranded +in the background. The desktop environment must provide a status icon host, +such as Ubuntu's AppIndicators extension, to show the top-panel icon. Run `node --test apps/desktop/scripts/desktop-lifecycle.test.mjs` from the repository root for the cross-platform controller checks. On macOS or Windows, @@ -77,13 +88,26 @@ Regenerate status icons from the checked-in SVG with pnpm --filter pilotdeck-desktop dist:mac:arm64 pnpm --filter pilotdeck-desktop dist:mac:x64 pnpm --filter pilotdeck-desktop dist:win +# On a native Ubuntu host with the matching CPU architecture: +pnpm --filter pilotdeck-desktop dist:linux:arm64 +pnpm --filter pilotdeck-desktop dist:linux:x64 ``` +Build Linux release packages on Ubuntu 22.04 LTS for the intended minimum +version. Native modules compiled on newer Ubuntu releases may require a newer +glibc and will not run on 22.04. A 4 GB build VM may need +`NODE_OPTIONS=--max-old-space-size=2560` for the Web UI build. Linux packages +use the system Git package and include a bundled Node.js runtime. +See [Ubuntu desktop support assessment](../../docs/ubuntu-desktop-support.md) +for the X11, Wayland, and XWayland test matrix and remaining release gates. + Platform release builds should run on matching GitHub Actions runners: - macOS arm64 DMG artifacts on `macos-latest` - macOS x64 DMG artifacts on `macos-15-intel` - Windows x64 NSIS installer artifacts on `windows-latest` +- Ubuntu x64 DEB artifacts on `ubuntu-22.04` +- Ubuntu arm64 DEB artifacts on `ubuntu-22.04-arm` macOS CI signs and notarizes release artifacts when the repository provides these GitHub Secrets: @@ -113,28 +137,35 @@ compatibility guarantees. macOS keeps native traffic lights in the sidebar's 48px top strip. The main header extends to the window edge. Collapsing the sidebar, entering compact mode or opening Settings reserves a full-width strip; fullscreen removes it. -Windows uses a 40px title-bar overlay with native caption buttons. The caption +Windows and Ubuntu use a 40px title-bar overlay with native caption buttons, +putting the application menus and window controls on one row. The caption has a neutral light/dark background; the main interface keeps its original sidebar gradient, layout, rounded frame, borders and viewport gutter. -File, Edit, View, Go and Help open the corresponding native submenus; Alt+F/E/V/G/H -access them directly and F10 opens the complete application menu, including in -fullscreen. Tab and arrow keys navigate caption buttons. Menu clicks preserve -the editor selection. While a popup is open, hovering or clicking another caption -menu switches directly to it. The main process tracks the native cursor during -that session because native popups capture renderer mouse events, and waits for -the previous popup to close before opening its replacement. +File, Edit, View, Go and Help open the corresponding menu sections; Alt+F/E/V/G/H +access them directly. F10 opens the application menu on Windows and the File +section on Linux, including in fullscreen. Tab and arrow keys navigate caption +buttons. Menu clicks preserve the editor selection. While a popup is open, +hovering or clicking another caption menu switches directly to it. Windows uses +native submenus and tracks the native cursor while they are open. Linux renders +the popup from the same current application menu template inside the window; +the native menu remains registered for keyboard accelerators and OS integration. Its drag region reserves the native controls' area through the Window Controls Overlay geometry; the workspace header also supports dragging. Alt does not reveal a duplicate system menu row. Windows fullscreen state is published after the native transition updates, removing/restoring the top inset. -Both platforms retain native frames, resizing and shadows. macOS removes the Web -viewport's extra gutter, border, rounded corners and shadow. Windows only reserves +All three platforms retain native frames, resizing and shadows. macOS removes the Web +viewport's extra gutter, border, rounded corners and shadow. Windows and Ubuntu reserve space for the caption; business components and browser/PWA styles are preserved. The caption is preload-owned, so it also works during startup and onboarding. Caption labels initialize from the saved desktop language and follow the UI's resolved i18n language immediately, together with the native application menu. -The native theme follows the app's light/dark/system preference. Opaque surfaces -are intentional; this implementation does not require vibrancy or Mica. +The caption, Linux popup and Electron native theme follow the app's +light/dark/system preference immediately. The Linux popup does not depend on a +particular GTK theme, panel extension or desktop environment, so the same menu +code applies on Ubuntu, Kylin, Deepin and UOS when their Electron and package +dependencies are compatible. Distribution-level installation and tray behavior +still require testing on each target desktop. Opaque surfaces are intentional; +this implementation does not require vibrancy or Mica. The native menu routes New Conversation, New Project, Settings, Find, sidebar visibility, Conversation, Project Files, Skills, Scheduled Tasks and Check for @@ -170,13 +201,23 @@ pnpm --dir ui exec vite --host 127.0.0.1 --port 5187 --strictPort node ui/e2e/desktop-chrome.smoke.mjs ``` +On Ubuntu 22.04, run the same smoke suite under both X11 and a headless Wayland +compositor with `bash apps/desktop/scripts/run-linux-chrome-smoke.sh x11` and +`bash apps/desktop/scripts/run-linux-chrome-smoke.sh wayland`. It verifies the +single-row caption, menu commands, Chinese/English labels in the caption and +Linux popup, and light/dark menu colors. The Linux CI job runs both display +paths on x64 and arm64 before PR merge and in the daily release pipeline. + The smoke host uses the real compiled preload and application UI with a temporary Electron profile and mocked API/WebSocket traffic. It does not start the real runtime or send model requests. It invokes native menu callbacks to check commands, project inheritance, dialog blocking, sidebar collapse, repeated update checks, light/dark appearance, native fullscreen transitions and traffic-light position, -and minimize/restore. Windows also checks native popup requests, caption/sidebar -colors, control safe area, maximize/restore and fullscreen inset restoration. +and minimize/restore. Windows checks native popup requests; Linux checks its +interactive popup contents and live theme/language updates. Both check +caption/sidebar colors, control safe area and fullscreen inset restoration. +Windows additionally checks maximize/restore; the headless Linux compositors +have no window manager to verify maximize behavior. Set `PILOTDECK_CHROME_SCALE` to `1`, `1.25` or `1.5` for Windows scaling checks. Screenshots go to `outputs/desktop-chrome-review` (override with `PILOTDECK_CHROME_ARTIFACTS`). Run the lifecycle suite above as well when diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6e15b0f04..01e9f162b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,9 @@ "private": true, "description": "Cross-platform Electron shell for PilotDeck.", "author": "PilotDeck", + "homepage": "https://pilotdeck.openbmb.cn", + "license": "AGPL-3.0-only", + "desktopName": "pilotdeck-desktop", "main": "dist/main.js", "scripts": { "build:runtime": "node scripts/build-runtime.mjs", @@ -15,6 +18,8 @@ "dist:mac:x64": "PILOTDECK_DESKTOP_NODE_ARCH=x64 node scripts/build-runtime.mjs && tsc -p tsconfig.json && electron-builder --mac --x64 --publish never", "dist:win": "node scripts/build-runtime.mjs && tsc -p tsconfig.json && electron-builder --win --publish never", "dist:win:portable": "node scripts/build-runtime.mjs && tsc -p tsconfig.json && electron-builder --win portable --publish never", + "dist:linux:arm64": "node scripts/check-linux-build-host.mjs arm64 && umask 022 && PILOTDECK_DESKTOP_NODE_ARCH=arm64 node scripts/build-runtime.mjs && tsc -p tsconfig.json && electron-builder --linux deb --arm64 --publish never", + "dist:linux:x64": "node scripts/check-linux-build-host.mjs x64 && umask 022 && PILOTDECK_DESKTOP_NODE_ARCH=x64 node scripts/build-runtime.mjs && tsc -p tsconfig.json && electron-builder --linux deb --x64 --publish never", "download-git-bash": "node scripts/download-git-bash.mjs", "download-node": "node scripts/download-node.mjs", "download-playwright-browsers": "node scripts/download-playwright-browsers.mjs", @@ -106,6 +111,29 @@ } ] }, + "linux": { + "category": "Development", + "icon": "resources/icons/icon.png", + "maintainer": "OpenBMB ", + "syncDesktopName": true, + "target": ["deb"] + }, + "deb": { + "depends": [ + "git", + "libgtk-3-0", + "libasound2", + "libgbm1", + "libnotify4", + "libnss3", + "libxss1", + "libxtst6", + "xdg-utils", + "libatspi2.0-0", + "libuuid1", + "libsecret-1-0" + ] + }, "nsis": { "artifactName": "${productName}-${version}-${os}-${arch}-setup.${ext}", "oneClick": false, diff --git a/apps/desktop/scripts/after-pack.cjs b/apps/desktop/scripts/after-pack.cjs index faf66dbe9..95a2f2f24 100644 --- a/apps/desktop/scripts/after-pack.cjs +++ b/apps/desktop/scripts/after-pack.cjs @@ -1,4 +1,5 @@ const { + chmodSync, cpSync, existsSync, lstatSync, @@ -55,6 +56,21 @@ function materializeSymlinks(root) { return count; } +function makePackagedLinuxFilesReadable(root) { + function visit(file) { + const stat = lstatSync(file); + if (stat.isSymbolicLink()) return; + const mode = stat.mode & 0o7777; + if (stat.isDirectory()) { + chmodSync(file, mode | 0o055); + for (const entry of readdirSync(file)) visit(join(file, entry)); + } else if (stat.isFile()) { + chmodSync(file, mode | 0o044 | (mode & 0o111 ? 0o011 : 0)); + } + } + visit(root); +} + function hasOwn(object, key) { return Object.prototype.hasOwnProperty.call(object, key); } @@ -163,6 +179,12 @@ module.exports = async function afterPack(context) { ); verifyPackagedRuntime(target, context, "runtime"); + if (context.electronPlatformName === "linux") { + // electron-builder creates the DEB launcher after this hook. Do not let a + // restrictive build-shell umask make it unreadable to desktop users. + process.umask(0o022); + makePackagedLinuxFilesReadable(context.appOutDir); + } ensureMacSigningFallback(context); if (context.electronPlatformName === "win32") { await require("./prepare-windows-installer.cjs").prepareWindowsInstaller(desktopRoot); diff --git a/apps/desktop/scripts/check-linux-build-host.mjs b/apps/desktop/scripts/check-linux-build-host.mjs new file mode 100644 index 000000000..7f4e62687 --- /dev/null +++ b/apps/desktop/scripts/check-linux-build-host.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; + +const expectedArch = process.argv[2]; +if (process.platform !== "linux" || process.arch !== expectedArch) { + console.error(`Build the Linux ${expectedArch} package on a native Linux ${expectedArch} host.`); + process.exit(1); +} + +const osRelease = readFileSync("/etc/os-release", "utf8"); +if (!/^ID=ubuntu$/m.test(osRelease) || !/^VERSION_ID="?22\.04"?$/m.test(osRelease)) { + console.error("Build release DEBs on Ubuntu 22.04 LTS to preserve the minimum supported glibc version."); + process.exit(1); +} diff --git a/apps/desktop/scripts/create-release-manifest.mjs b/apps/desktop/scripts/create-release-manifest.mjs index 1aa01ada4..384824f5a 100644 --- a/apps/desktop/scripts/create-release-manifest.mjs +++ b/apps/desktop/scripts/create-release-manifest.mjs @@ -50,6 +50,7 @@ function inferPlatform(file) { const name = basename(file).toLowerCase(); if (name.endsWith(".dmg") || name.includes("mac")) return "darwin"; if (name.endsWith(".exe") || name.includes("win")) return "win32"; + if (name.endsWith(".deb") || name.includes("linux")) return "linux"; return "unknown"; } diff --git a/apps/desktop/scripts/create-release-manifest.test.mjs b/apps/desktop/scripts/create-release-manifest.test.mjs index 3fdfceb29..bc299b74c 100644 --- a/apps/desktop/scripts/create-release-manifest.test.mjs +++ b/apps/desktop/scripts/create-release-manifest.test.mjs @@ -20,7 +20,11 @@ test("unified release manifest links the source tag and checksummed installers", "latest-arm64-mac.yml", "latest-x64-mac.yml", "latest-x64.yml", + "latest-linux.yml", + "latest-linux-arm64.yml", "PilotDeck-2026.903.0-win-x64-setup.exe", + "PilotDeck-2026.903.0-linux-amd64.deb", + "PilotDeck-2026.903.0-linux-arm64.deb", ]) { writeFileSync(resolve(assetsDir, name), name); } @@ -66,11 +70,15 @@ test("unified release manifest links the source tag and checksummed installers", { name: "latest-arm64-mac.yml", platform: "darwin", arch: "arm64" }, { name: "latest-x64-mac.yml", platform: "darwin", arch: "x64" }, { name: "latest-x64.yml", platform: "unknown", arch: "x64" }, + { name: "latest-linux.yml", platform: "linux", arch: "unknown" }, + { name: "latest-linux-arm64.yml", platform: "linux", arch: "arm64" }, { name: "PilotDeck-2026.903.0-mac-arm64.zip", platform: "darwin", arch: "arm64" }, { name: "PilotDeck-2026.903.0-mac-x64.zip", platform: "darwin", arch: "x64" }, { name: "PilotDeck-2026.903.0-mac-arm64.dmg", platform: "darwin", arch: "arm64" }, { name: "PilotDeck-2026.903.0-mac-x64.dmg", platform: "darwin", arch: "x64" }, { name: "PilotDeck-2026.903.0-win-x64-setup.exe", platform: "win32", arch: "x64" }, + { name: "PilotDeck-2026.903.0-linux-amd64.deb", platform: "linux", arch: "x64" }, + { name: "PilotDeck-2026.903.0-linux-arm64.deb", platform: "linux", arch: "arm64" }, ].sort((left, right) => left.name.localeCompare(right.name)), ); } finally { diff --git a/apps/desktop/scripts/desktop-lifecycle.test.mjs b/apps/desktop/scripts/desktop-lifecycle.test.mjs index 8c69a7667..6c9313dcb 100644 --- a/apps/desktop/scripts/desktop-lifecycle.test.mjs +++ b/apps/desktop/scripts/desktop-lifecycle.test.mjs @@ -133,12 +133,34 @@ test('update/system quit bypasses hiding and confirmation, including an outstand assert.equal(state.dialogs.length, 1); }); -test('tray failure never hides the only accessible window', () => { - const { state, window, controller, close } = setup({ failTray: true }); - assert.equal(controller.available(), false); - assert.equal(close(), false); +for (const platform of ['win32', 'linux']) { + test(`${platform}: tray failure never hides the only accessible window`, () => { + const { state, window, controller, close } = setup({ platform, failTray: true }); + assert.equal(controller.available(), false); + assert.equal(close(), false); + assert.equal(window.visible, true); + assert.equal(state.errors.length, 1); + }); +} + +test('Linux close keeps tasks running; tray activation restores and Quit asks for confirmation', async () => { + const { state, window, tray, close } = setup({ platform: 'linux' }); + assert.equal(close(), true); + assert.equal(window.visible, false); + tray.emit('click'); + await tick(); assert.equal(window.visible, true); - assert.equal(state.errors.length, 1); + assert.equal(close(), true); + tray.menu[0].click(); + await tick(); + assert.equal(window.visible, true); + tray.menu[2].click(); + await tick(); + assert.equal(state.dialogs.length, 1); + assert.equal(state.dialogs[0].owner, window); + state.dialogs[0].resolve({ response: 0 }); + await tick(); + assert.equal(state.quits, 0); }); test('dialog failure is recoverable and language changes update both menu and confirmation', async () => { @@ -175,7 +197,7 @@ test('File > Exit shares confirmation on Windows; other platforms retain native } }); -for (const platform of ['darwin', 'win32']) { +for (const platform of ['darwin', 'win32', 'linux']) { test(`${platform}: native quit is intercepted, cleanup happens once, failure can be retried`, async () => { let stops = 0; const { state, window, controller, close } = setup({ platform, stopRuntime: async () => { diff --git a/apps/desktop/scripts/fixtures/desktop-chrome.cjs b/apps/desktop/scripts/fixtures/desktop-chrome.cjs index edcc9733e..6ce76d131 100644 --- a/apps/desktop/scripts/fixtures/desktop-chrome.cjs +++ b/apps/desktop/scripts/fixtures/desktop-chrome.cjs @@ -6,6 +6,7 @@ const os = require('node:os'); const { isRendererEditingShortcut, windowChromeOptions, windowPalette, WINDOWS_CAPTION_HEIGHT } = require('../../dist/windowChrome'); const { buildApplicationMenu } = require('../../dist/applicationMenu'); const { WindowsCaptionMenu } = require('../../dist/windowsCaptionMenu'); +const { linuxCaptionEntries, linuxCaptionAction } = require('../../dist/linuxCaptionMenu'); const { normalizeMenuState, emptyMenuState, commandEnabled } = require('../../dist/desktopCommands'); const profile = process.env.PILOTDECK_CHROME_PROFILE || fs.mkdtempSync(path.join(os.tmpdir(), 'pilotdeck-chrome-')); app.setPath('userData', profile); @@ -23,13 +24,14 @@ function menuTemplate() { } function refresh() { Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate())); - if (process.platform === 'win32' && window) window.setMenuBarVisibility(false); + if ((process.platform === 'win32' || process.platform === 'linux') && window) window.setMenuBarVisibility(false); + if (process.platform === 'linux' && window && !window.isDestroyed()) window.webContents.send('pilotdeck:application-menu-updated'); } function publish() { if (!window) return; const fullscreen = window.isFullScreen(); window.setBackgroundColor(windowPalette(nativeTheme.shouldUseDarkColors).background); - if (process.platform === 'win32' && !fullscreen) window.setTitleBarOverlay({ + if ((process.platform === 'win32' || process.platform === 'linux') && !fullscreen) window.setTitleBarOverlay({ color: windowPalette(nativeTheme.shouldUseDarkColors).caption, symbolColor: windowPalette(nativeTheme.shouldUseDarkColors).symbol, height: WINDOWS_CAPTION_HEIGHT, }); @@ -47,11 +49,41 @@ ipcMain.handle('pilotdeck:show-menu', (_e, request) => { captionMenu ||= new WindowsCaptionMenu(window, menuTemplate, items => Menu.buildFromTemplate(items), () => screen.getCursorScreenPoint()); return captionMenu.show(request); }); +ipcMain.handle('pilotdeck:linux-menu-items', (_e, id) => { + if (process.platform !== 'linux') return []; + menuRequests.push({ id }); + return linuxCaptionEntries(menuTemplate(), id); +}); +ipcMain.handle('pilotdeck:linux-menu-activate', (_e, request) => { + if (process.platform !== 'linux') return false; + const item = linuxCaptionAction(menuTemplate(), request?.id, request?.index); + if (!item) return false; + if (item.role) { + const contents = window.webContents; + switch (item.role) { + case 'undo': contents.undo(); break; + case 'redo': contents.redo(); break; + case 'cut': contents.cut(); break; + case 'copy': contents.copy(); break; + case 'paste': contents.paste(); break; + case 'selectAll': contents.selectAll(); break; + case 'resetZoom': contents.setZoomLevel(0); break; + case 'zoomIn': contents.setZoomLevel(contents.getZoomLevel() + 1); break; + case 'zoomOut': contents.setZoomLevel(contents.getZoomLevel() - 1); break; + case 'togglefullscreen': window.setFullScreen(!window.isFullScreen()); break; + case 'reload': contents.reload(); break; + case 'close': window.close(); break; + case 'quit': app.quit(); break; + } + } else item.click?.(); + return true; +}); nativeTheme.on('updated', publish); global.chromeTest = { state: () => state, checks: () => checks, menuRequests: () => menuRequests, closeMenu: () => captionMenu?.close(), refreshMenu: refresh }; app.whenReady().then(async () => { nativeTheme.themeSource = 'dark'; window = new BrowserWindow({ ...windowChromeOptions(process.platform, true), show: false, width: 1320, height: 900, + autoHideMenuBar: process.platform === 'win32' || process.platform === 'linux', webPreferences: { preload: path.resolve(__dirname, '../../dist/preload.js'), contextIsolation: true, sandbox: false, nodeIntegration: false }, }); window.once('ready-to-show', () => window.show()); diff --git a/apps/desktop/scripts/run-linux-chrome-smoke.sh b/apps/desktop/scripts/run-linux-chrome-smoke.sh new file mode 100644 index 000000000..8355ec76f --- /dev/null +++ b/apps/desktop/scripts/run-linux-chrome-smoke.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail +node_binary="${PILOTDECK_CHROME_NODE_BINARY:-node}" + +mode="${1:?Specify x11 or wayland}" +if [[ "$mode" != x11 && "$mode" != wayland ]]; then + echo "Unsupported display mode: $mode" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$repo_root" +export PILOTDECK_CHROME_OZONE="$mode" +export PILOTDECK_CHROME_DISABLE_SANDBOX=1 +export PILOTDECK_CHROME_ARTIFACTS="$repo_root/outputs/desktop-chrome-review/linux-$mode" +mkdir -p "$PILOTDECK_CHROME_ARTIFACTS" +if ! "$node_binary" -e "require('./apps/desktop/node_modules/electron')" >/dev/null 2>&1; then + "$node_binary" apps/desktop/node_modules/electron/install.js +fi + +(cd ui && exec "$node_binary" node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5187 --strictPort) \ + >"${TMPDIR:-/tmp}/pilotdeck-chrome-vite-$mode.log" 2>&1 & +vite_pid=$! +runtime_dir="" +weston_pid="" +cleanup() { + if [[ -n "$weston_pid" ]]; then + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + fi + kill "$vite_pid" 2>/dev/null || true + wait "$vite_pid" 2>/dev/null || true + if [[ -n "$runtime_dir" ]]; then rm -rf -- "$runtime_dir"; fi +} +trap cleanup EXIT + +for ((attempt = 0; attempt < 200; attempt++)); do + if curl --silent --fail http://127.0.0.1:5187/ >/dev/null; then break; fi + if ! kill -0 "$vite_pid" 2>/dev/null; then + cat "${TMPDIR:-/tmp}/pilotdeck-chrome-vite-$mode.log" >&2 + exit 1 + fi + sleep 0.1 +done +curl --silent --fail http://127.0.0.1:5187/ >/dev/null + +if [[ "$mode" == x11 ]]; then + exec_mode=(dbus-run-session -- xvfb-run -a -s '-screen 0 1440x900x24') +else + runtime_dir="$(mktemp -d)" + chmod 700 "$runtime_dir" + export XDG_RUNTIME_DIR="$runtime_dir" + export WAYLAND_DISPLAY=wayland-pilotdeck-chrome + unset DISPLAY + weston --backend=headless-backend.so --socket="$WAYLAND_DISPLAY" \ + --no-config --idle-time=0 --width=1440 --height=900 >"$runtime_dir/weston.log" 2>&1 & + weston_pid=$! + for ((attempt = 0; attempt < 100; attempt++)); do + if [[ -S "$runtime_dir/$WAYLAND_DISPLAY" ]]; then break; fi + if ! kill -0 "$weston_pid" 2>/dev/null; then + cat "$runtime_dir/weston.log" >&2 + exit 1 + fi + sleep 0.1 + done + test -S "$runtime_dir/$WAYLAND_DISPLAY" + exec_mode=(dbus-run-session --) +fi + +"${exec_mode[@]}" "$node_binary" ui/e2e/desktop-chrome.smoke.mjs diff --git a/apps/desktop/scripts/run-linux-display-smoke.sh b/apps/desktop/scripts/run-linux-display-smoke.sh new file mode 100644 index 000000000..427fd8033 --- /dev/null +++ b/apps/desktop/scripts/run-linux-display-smoke.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:?Specify x11 or wayland}" +script_dir="$(cd "$(dirname "$0")" && pwd)" +node_binary="${PILOTDECK_NODE_BINARY:-/opt/PilotDeck/resources/node/bin/node}" + +if [[ "$mode" == x11 ]]; then + exec dbus-run-session -- xvfb-run -a -s '-screen 0 1440x900x24' \ + "$node_binary" "$script_dir/verify-linux-boot.cjs" x11 +fi + +if [[ "$mode" != wayland ]]; then + echo "Unsupported display mode: $mode" >&2 + exit 1 +fi + +runtime_dir="$(mktemp -d)" +chmod 700 "$runtime_dir" +export XDG_RUNTIME_DIR="$runtime_dir" +export WAYLAND_DISPLAY=wayland-pilotdeck-smoke +unset DISPLAY +weston_log="$runtime_dir/weston.log" +weston --backend=headless-backend.so --socket="$WAYLAND_DISPLAY" \ + --no-config --idle-time=0 --width=1440 --height=900 >"$weston_log" 2>&1 & +weston_pid=$! +cleanup() { + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + rm -rf -- "$runtime_dir" +} +trap cleanup EXIT + +for ((attempt = 0; attempt < 100; attempt++)); do + if [[ -S "$runtime_dir/$WAYLAND_DISPLAY" ]]; then break; fi + if ! kill -0 "$weston_pid" 2>/dev/null; then + cat "$weston_log" >&2 + exit 1 + fi + sleep 0.1 +done +if [[ ! -S "$runtime_dir/$WAYLAND_DISPLAY" ]]; then + cat "$weston_log" >&2 + echo 'Weston did not create a Wayland socket' >&2 + exit 1 +fi +dbus-run-session -- "$node_binary" "$script_dir/verify-linux-boot.cjs" wayland diff --git a/apps/desktop/scripts/verify-linux-boot.cjs b/apps/desktop/scripts/verify-linux-boot.cjs new file mode 100644 index 000000000..5be94ba46 --- /dev/null +++ b/apps/desktop/scripts/verify-linux-boot.cjs @@ -0,0 +1,99 @@ +// Launch the installed DEB under an isolated X11 or Wayland compositor. +// This checks the real Electron executable and bundled UI server without +// touching the runner's home directory or contacting a model provider. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const display = process.argv[2]; +assert.ok(['x11', 'wayland'].includes(display), 'Specify x11 or wayland'); +assert.equal(process.platform, 'linux'); +assert.ok(display === 'x11' ? process.env.DISPLAY : process.env.WAYLAND_DISPLAY, + `Missing ${display} display socket`); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), `pilotdeck-${display}-smoke-`)); +const configHome = path.join(root, 'config'); +const runtimeLog = path.join(configHome, 'pilotdeck-desktop', 'logs', 'runtime.log'); +const child = spawn('/usr/bin/pilotdeck-desktop', [ + `--ozone-platform=${display}`, '--disable-gpu', '--no-sandbox', +], { + env: { ...process.env, XDG_CONFIG_HOME: configHome, PILOT_HOME: path.join(root, 'pilot-home') }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, +}); +let output = ''; +let exited = false; +child.on('exit', () => { exited = true; }); +for (const stream of [child.stdout, child.stderr]) { + stream.on('data', chunk => { output = (output + chunk.toString()).slice(-12_000); }); +} +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function waitForReady() { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + if (exited) throw new Error(`Electron exited before startup:\n${output}`); + if (fs.existsSync(runtimeLog)) { + const log = fs.readFileSync(runtimeLog, 'utf8'); + const url = log.match(/PilotDeck Web UI ready: (http:\/\/127\.0\.0\.1:\d+)/)?.[1]; + if (url) { + const response = await fetch(url).catch(() => null); + if (response?.ok) return url; + } + if (log.includes('PilotDeck failed to start')) throw new Error(log.slice(-6000)); + } + await pause(250); + } + throw new Error(`Timed out waiting for PilotDeck UI:\n${output}\n${fs.existsSync(runtimeLog) ? fs.readFileSync(runtimeLog, 'utf8').slice(-6000) : 'No runtime log'}`); +} + +async function stop() { + if (!exited && child.pid) { + try { process.kill(-child.pid, 'SIGTERM'); } catch { /* already exited */ } + const deadline = Date.now() + 5_000; + while (!exited && Date.now() < deadline) await pause(100); + if (!exited) { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + // Managed Node processes intentionally live in separate process groups. + // Match only this smoke run's private PILOT_HOME before terminating them. + const marker = Buffer.from(`\0PILOT_HOME=${path.join(root, 'pilot-home')}\0`); + const ownedPids = () => fs.readdirSync('/proc').filter(name => /^\d+$/.test(name)) + .map(Number).filter(pid => { + if (pid === process.pid) return false; + try { + const env = fs.readFileSync(`/proc/${pid}/environ`); + return Buffer.concat([Buffer.from([0]), env]).includes(marker); + } catch { return false; } + }); + for (const pid of ownedPids()) { + try { process.kill(pid, 'SIGTERM'); } catch { /* already exited */ } + } + const deadline = Date.now() + 5_000; + while (ownedPids().length && Date.now() < deadline) await pause(100); + for (const pid of ownedPids()) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + fs.rmSync(root, { recursive: true, force: true, maxRetries: 3 }); +} + +async function main() { + try { + const url = await waitForReady(); + await pause(2_000); + assert.equal(exited, false, `Electron exited after readiness:\n${output}`); + if (display === 'x11') { + const { execFileSync } = require('node:child_process'); + const windows = execFileSync('xwininfo', ['-root', '-tree'], { encoding: 'utf8' }); + assert.match(windows, /PilotDeck/, 'X11 window was not created'); + } + console.log(`PASS: installed DEB started on ${display} and served ${url}`); + } finally { + await stop(); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/apps/desktop/scripts/verify-linux-package.cjs b/apps/desktop/scripts/verify-linux-package.cjs new file mode 100644 index 000000000..e8dff9d3e --- /dev/null +++ b/apps/desktop/scripts/verify-linux-package.cjs @@ -0,0 +1,63 @@ +// Run with the Node.js binary inside an installed PilotDeck DEB. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); + +const installRoot = '/opt/PilotDeck'; +const resources = path.join(installRoot, 'resources'); +const runtime = path.join(resources, 'runtime'); +const expectedArch = process.argv[2]; +assert.equal(process.platform, 'linux'); +assert.equal(process.arch, expectedArch); +assert.equal(fs.realpathSync(process.execPath), fs.realpathSync(path.join(resources, 'node/bin/node'))); +assert.equal(fs.readFileSync(path.join(resources, 'package-type'), 'utf8').trim(), 'deb'); +assert.ok(fs.statSync(path.join(resources, 'app.asar')).size > 0); +const iconPath = '/usr/share/icons/hicolor/256x256/apps/pilotdeck-desktop.png'; +const launcherPath = '/usr/share/applications/pilotdeck-desktop.desktop'; +assert.ok(fs.statSync(iconPath).size > 0); +assert.ok(fs.statSync(iconPath).mode & 0o004, 'installed icon must be readable by desktop users'); +assert.ok(fs.statSync(launcherPath).mode & 0o004, 'desktop launcher must be readable by desktop users'); +assert.match(fs.readFileSync(launcherPath, 'utf8'), /^Icon=pilotdeck-desktop$/m); + +const runtimeRequire = createRequire(path.join(runtime, 'package.json')); +const Database = runtimeRequire('better-sqlite3'); +const db = new Database(':memory:'); +try { + assert.equal(db.prepare('SELECT 42 AS value').get().value, 42); +} finally { + db.close(); +} + +const bcrypt = runtimeRequire('bcrypt'); +const hash = bcrypt.hashSync('linux-smoke', 4); +assert.equal(bcrypt.compareSync('linux-smoke', hash), true); + +async function verifyPty() { + const pty = runtimeRequire('node-pty'); + const child = pty.spawn('/bin/sh', ['-c', 'printf pilotdeck-pty-ok'], { + name: 'xterm', cols: 80, rows: 24, cwd: '/tmp', env: process.env, + }); + let output = ''; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { child.kill(); reject(new Error('PTY smoke timed out')); }, 10_000); + child.onData(chunk => { output += chunk; }); + child.onExit(({ exitCode }) => { + clearTimeout(timeout); + if (exitCode === 0) resolve(); + else reject(new Error(`PTY exited with ${exitCode}`)); + }); + }); + assert.match(output, /pilotdeck-pty-ok/); +} + +async function main() { + const sharp = runtimeRequire('sharp'); + const image = await sharp({ create: { width: 2, height: 2, channels: 4, background: '#123456' } }) + .png().toBuffer(); + assert.equal((await sharp(image).metadata()).width, 2); + await verifyPty(); + console.log(`PASS: installed Linux ${expectedArch} DEB, launcher, icon and native modules`); +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/apps/desktop/scripts/verify-update-network.cjs b/apps/desktop/scripts/verify-update-network.cjs index 62865974e..0d96c774a 100644 --- a/apps/desktop/scripts/verify-update-network.cjs +++ b/apps/desktop/scripts/verify-update-network.cjs @@ -1,7 +1,7 @@ // Run with Electron after desktop compile. No windows or external network access. // Exercises the actual discovery fetch and electron-updater download executor. const { app } = require('electron'); -const { MacUpdater, NsisUpdater } = require('electron-updater'); +const { DebUpdater, MacUpdater, NsisUpdater } = require('electron-updater'); const { createUpdateNetwork } = require('../dist/updateNetwork.js'); const { getLatestRelease } = require('../../../ui/server/services/releaseService.js'); const { createServer } = require('node:http'); @@ -24,7 +24,7 @@ app.whenReady().then(async () => { const sha512 = createHash('sha512').update(payload).digest('base64'); const asset = { name: 'PilotDeck-test.zip', platform: 'darwin', arch: 'arm64', size: payload.length, sha256: createHash('sha256').update(payload).digest('hex'), sha512 }; - const manifest = { schemaVersion: 1, repository: 'fixture/PilotDeck', tag: 'v2026.09.07', version: '2026.907.0', sourceSha: 'a'.repeat(40), assets: [asset] }; + const manifest = { schemaVersion: 1, repository: 'fixture/PilotDeck', tag: 'v2026.09.07', date: '2026-09-07', version: '2026.907.0', sourceSha: 'a'.repeat(40), assets: [asset] }; const requests = []; execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(root, 'key.pem'), '-out', path.join(root, 'cert.pem'), '-days', '1', '-subj', '/CN=pilotdeck-update-fixture'], { stdio: 'ignore' }); const origin = https.createServer({ key: fs.readFileSync(path.join(root, 'key.pem')), cert: fs.readFileSync(path.join(root, 'cert.pem')) }, (req, res) => { @@ -61,7 +61,8 @@ app.whenReady().then(async () => { for (const stream of [socket, upstream]) { tunnels.add(stream); stream.on('error', () => {}); stream.on('close', () => tunnels.delete(stream)); } }); await listen(proxy); - const updater = process.platform === 'win32' ? new NsisUpdater() : new MacUpdater(); + const updater = process.platform === 'win32' ? new NsisUpdater() + : process.platform === 'linux' ? new DebUpdater() : new MacUpdater(); updater.netSession.setCertificateVerifyProc((request, callback) => callback(['github.com', 'release-assets.githubusercontent.com'].includes(request.hostname) ? 0 : -3)); let activeNetwork; updater.on('login', (auth, callback) => { diff --git a/apps/desktop/scripts/verify-updater.cjs b/apps/desktop/scripts/verify-updater.cjs index 3409423c5..42a40cdc3 100644 --- a/apps/desktop/scripts/verify-updater.cjs +++ b/apps/desktop/scripts/verify-updater.cjs @@ -11,6 +11,7 @@ assert.ok(entry.startsWith(archive + path.sep), 'electron-updater resolved outsi const updater = packagedRequire('electron-updater'); assert.equal(typeof updater.MacUpdater, 'function'); assert.equal(typeof updater.NsisUpdater, 'function'); +assert.equal(typeof updater.DebUpdater, 'function'); assert.equal(typeof packagedRequire('./dist/updates.js').createUpdateController, 'function'); const visited = new Set(); function inspect(module) { diff --git a/apps/desktop/src/desktopTray.ts b/apps/desktop/src/desktopTray.ts index 7d6a5f2c5..5d75822e7 100644 --- a/apps/desktop/src/desktopTray.ts +++ b/apps/desktop/src/desktopTray.ts @@ -32,8 +32,10 @@ export function createDesktopTray(options: Options) { tray = options.createTray(); tray.setToolTip('PilotDeck'); // macOS opens its native status menu on click. Do not also activate a window. - if (options.platform === 'win32') { + if (options.platform === 'win32' || options.platform === 'linux') { tray.on('click', () => invoke(options.open)); + } + if (options.platform === 'win32') { tray.on('double-click', () => invoke(options.open)); } refreshMenu(); diff --git a/apps/desktop/src/linuxCaptionMenu.ts b/apps/desktop/src/linuxCaptionMenu.ts new file mode 100644 index 000000000..992fdd0f8 --- /dev/null +++ b/apps/desktop/src/linuxCaptionMenu.ts @@ -0,0 +1,37 @@ +import type { MenuItemConstructorOptions } from 'electron'; +import { WINDOWS_MENUS } from './windowChrome'; + +export type LinuxCaptionEntry = { + index: number; + id?: string; + label: string; + accelerator?: string; + type: 'normal' | 'separator' | 'checkbox'; + enabled: boolean; + checked: boolean; +}; + +function submenu(template: MenuItemConstructorOptions[], id: unknown): MenuItemConstructorOptions[] | undefined { + if (!WINDOWS_MENUS.some(menu => menu.id === id)) return; + const items = template.find(item => item.id === id)?.submenu; + return Array.isArray(items) ? items : undefined; +} + +export function linuxCaptionEntries(template: MenuItemConstructorOptions[], id: unknown): LinuxCaptionEntry[] { + return (submenu(template, id) ?? []).flatMap((item, index) => item.visible === false ? [] : [{ + index, + ...(item.id ? { id: item.id } : {}), + label: item.label ?? '', + ...(item.accelerator ? { accelerator: item.accelerator.replace(/CmdOrCtrl/g, 'Ctrl').replace(/Comma/g, ',') } : {}), + type: item.type === 'separator' ? 'separator' : item.type === 'checkbox' ? 'checkbox' : 'normal', + enabled: item.type !== 'separator' && item.enabled !== false, + checked: item.checked === true, + }]); +} + +export function linuxCaptionAction(template: MenuItemConstructorOptions[], id: unknown, index: unknown): MenuItemConstructorOptions | undefined { + if (!Number.isSafeInteger(index) || (index as number) < 0) return; + const item = submenu(template, id)?.[index as number]; + if (!item || item.visible === false || item.enabled === false || item.type === 'separator') return; + return item; +} diff --git a/apps/desktop/src/linuxCaptionPopup.ts b/apps/desktop/src/linuxCaptionPopup.ts new file mode 100644 index 000000000..b6982e997 --- /dev/null +++ b/apps/desktop/src/linuxCaptionPopup.ts @@ -0,0 +1,158 @@ +import type { IpcRenderer } from 'electron'; +import type { LinuxCaptionEntry } from './linuxCaptionMenu'; +import { WINDOWS_MENUS } from './windowChrome'; + +type MenuIpc = Pick; + +/** Linux's GTK popup cannot follow Electron's per-app theme at runtime. Keep + * the native application menu for accelerators, and draw its title-bar popup + * from the same current menu template in the isolated preload. */ +export function createLinuxCaptionPopup( + root: HTMLElement, + buttons: HTMLButtonElement[], + ipc: MenuIpc, + setActiveMenu: (id?: string) => void, +) { + const host = document.createElement('div'); + host.id = 'pilotdeck-linux-popup'; + host.hidden = true; + const shadow = host.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = ` + :host { position:fixed; inset:0; z-index:10001; pointer-events:none; color:#262626; + font:13px system-ui; -webkit-app-region:no-drag; } + :host([hidden]) { display:none; } + :host([data-dark]) { color:#eee; } + .panel { position:fixed; box-sizing:border-box; min-width:250px; max-width:min(420px,calc(100vw - 16px)); + max-height:calc(100vh - 48px); overflow:auto; padding:6px; border-radius:9px; + border:1px solid #dadada; background:#fff; box-shadow:0 12px 28px #0003; + pointer-events:auto; -webkit-app-region:no-drag; } + :host([data-dark]) .panel { border-color:#474747; background:#252525; box-shadow:0 12px 28px #0009; } + .entry { display:flex; align-items:center; gap:20px; width:100%; min-height:30px; + padding:4px 10px; box-sizing:border-box; border:0; border-radius:5px; + background:transparent; color:inherit; font:inherit; text-align:left; white-space:nowrap; cursor:default; } + .entry:hover:not(:disabled),.entry:focus-visible { background:#eae9f8; outline:0; } + :host([data-dark]) .entry:hover:not(:disabled),:host([data-dark]) .entry:focus-visible { background:#44435d; } + .entry:disabled { opacity:.48; } + .check { width:14px; flex:none; text-align:center; } + .label { flex:1; } + .shortcut { color:#777; margin-left:auto; } + :host([data-dark]) .shortcut { color:#aaa; } + .separator { height:1px; margin:6px -6px; background:#e5e5e5; } + :host([data-dark]) .separator { background:#494949; } + `; + const panel = document.createElement('div'); + panel.className = 'panel'; + panel.setAttribute('role', 'menu'); + shadow.append(style, panel); + document.body.append(host); + const captionHost = (buttons[0].getRootNode() as ShadowRoot).host; + + let activeId: string | undefined; + let serial = 0; + let previousFocus: HTMLElement | null = null; + const setTheme = () => host.toggleAttribute('data-dark', root.hasAttribute('data-desktop-dark')); + setTheme(); + new MutationObserver(setTheme).observe(root, { attributes: true, attributeFilter: ['data-desktop-dark'] }); + + const close = (focusCaption = false) => { + ++serial; + const id = activeId; + activeId = undefined; + host.hidden = true; + panel.replaceChildren(); + setActiveMenu(); + if (focusCaption && id) buttons.find(button => button.dataset.menu === id)?.focus(); + }; + + const open = async (id: string, toggle = false, focusFirst = false) => { + if (toggle && activeId === id) { close(true); return; } + if (!activeId) previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const request = ++serial; + activeId = id; + setActiveMenu(id); + let entries: LinuxCaptionEntry[]; + try { entries = await ipc.invoke('pilotdeck:linux-menu-items', id) as LinuxCaptionEntry[]; } + catch (error) { console.warn('Could not read application menu', error); close(); return; } + if (request !== serial || activeId !== id) return; + panel.replaceChildren(); + const activate = (entry: LinuxCaptionEntry) => { + const focus = previousFocus; + close(); + if (focus?.isConnected && focus !== document.body && focus !== captionHost) focus.focus({ preventScroll: true }); + void ipc.invoke('pilotdeck:linux-menu-activate', { id, index: entry.index }) + .catch(error => console.warn('Could not run application menu action', error)); + }; + for (const entry of entries) { + if (entry.type === 'separator') { + const separator = document.createElement('div'); + separator.className = 'separator'; + separator.setAttribute('role', 'separator'); + panel.append(separator); + continue; + } + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'entry'; + button.dataset.index = String(entry.index); + if (entry.id) button.dataset.action = entry.id; + button.setAttribute('role', entry.type === 'checkbox' ? 'menuitemcheckbox' : 'menuitem'); + if (entry.type === 'checkbox') button.setAttribute('aria-checked', String(entry.checked)); + button.disabled = !entry.enabled; + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = entry.checked ? '✓' : ''; + const label = document.createElement('span'); + label.className = 'label'; + label.textContent = entry.label; + button.append(check, label); + if (entry.accelerator) { + const shortcut = document.createElement('span'); + shortcut.className = 'shortcut'; + shortcut.textContent = entry.accelerator; + button.append(shortcut); + } + button.addEventListener('pointerdown', event => event.preventDefault()); + button.addEventListener('click', () => activate(entry)); + panel.append(button); + } + panel.setAttribute('aria-label', buttons.find(button => button.dataset.menu === id)?.textContent ?? id); + host.hidden = false; + const anchor = buttons.find(button => button.dataset.menu === id)?.getBoundingClientRect(); + const top = root.hasAttribute('data-desktop-fullscreen') ? 8 : (anchor?.bottom ?? 40); + panel.style.top = `${Math.max(0, top)}px`; + panel.style.left = `${Math.max(8, Math.min(anchor?.left ?? 12, innerWidth - panel.offsetWidth - 8))}px`; + if (focusFirst) panel.querySelector('.entry:not(:disabled)')?.focus(); + }; + + panel.addEventListener('keydown', event => { + if (!activeId) return; + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + const index = WINDOWS_MENUS.findIndex(menu => menu.id === activeId); + const next = (index + (event.key === 'ArrowRight' ? 1 : WINDOWS_MENUS.length - 1)) % WINDOWS_MENUS.length; + void open(WINDOWS_MENUS[next].id, false, true); + return; + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return; + event.preventDefault(); + const entries = [...panel.querySelectorAll('.entry:not(:disabled)')]; + if (!entries.length) return; + const current = entries.indexOf(shadow.activeElement as HTMLButtonElement); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? entries.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : entries.length - 1)) % entries.length; + entries[next].focus(); + }); + document.addEventListener('pointerdown', event => { + if (activeId && !host.contains(event.target as Node) + && !captionHost.contains(event.target as Node)) close(); + }, true); + window.addEventListener('keydown', event => { + if (activeId && event.key === 'Escape') { event.preventDefault(); close(true); } + }); + window.addEventListener('blur', () => { if (activeId) close(); }); + window.addEventListener('resize', () => { if (activeId) close(); }); + ipc.on('pilotdeck:application-menu-updated', () => { if (activeId) void open(activeId); }); + + return { open, close, refresh: () => { if (activeId) void open(activeId); } }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f54094b72..3a7688ad7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,12 +2,13 @@ import { installRendererRecovery } from "./rendererRecovery"; import { commandEnabled, emptyMenuState, normalizeMenuState, type DesktopCommand } from "./desktopCommands"; import { isRendererEditingShortcut, windowChromeOptions, windowPalette, WINDOWS_CAPTION_HEIGHT } from "./windowChrome"; import { WindowsCaptionMenu, type CaptionMenuRequest } from "./windowsCaptionMenu"; +import { linuxCaptionAction, linuxCaptionEntries } from "./linuxCaptionMenu"; import { buildApplicationMenu } from "./applicationMenu"; import { createDesktopTray } from "./desktopTray"; import { createDesktopLifecycle } from "./desktopLifecycle"; import { normalizeAppearance, renderLoadingHtml, startupText, type DesktopAppearance } from "./appearance"; import { app, BrowserWindow, dialog, ipcMain, Menu, shell, Tray, nativeImage, powerMonitor, nativeTheme, clipboard, screen } from "electron"; -import { MacUpdater, NsisUpdater } from "electron-updater"; +import { DebUpdater, MacUpdater, NsisUpdater } from "electron-updater"; import { createUpdateController } from "./updates"; import { createUpdateNetwork } from "./updateNetwork"; import { spawn, type ChildProcess } from "node:child_process"; @@ -522,8 +523,8 @@ async function createOrShowWindow(): Promise { minHeight: 640, title: "PilotDeck", ...(icon ? { icon } : {}), - // Alt must not reveal a second native menu row above the custom caption. - autoHideMenuBar: process.platform !== "win32", + // Windows and Linux show their application menus in the title-bar overlay. + autoHideMenuBar: process.platform === "win32" || process.platform === "linux", webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, @@ -920,7 +921,8 @@ function getUpdateController() { // discovery module as Web, from the packaged runtime outside app.asar. const releases = require(path.join(resolveRuntimeRoot(), "ui/server/services/releaseService.js")); const repository = releases.normalizeRepository(process.env.PILOTDECK_UPDATE_REPOSITORY || readBuildMetadata().repository || DEFAULT_UPDATE_REPOSITORY); - const updater = process.platform === "darwin" ? new MacUpdater() : new NsisUpdater(); + const updater = process.platform === "darwin" ? new MacUpdater() + : process.platform === "win32" ? new NsisUpdater() : new DebUpdater(); // The updater launches NSIS without a directory-page choice. Pass the // executable's actual installed directory so custom paths survive upgrades. if (updater instanceof NsisUpdater && app.isPackaged) updater.installDirectory = path.dirname(app.getPath("exe")); @@ -1022,17 +1024,57 @@ function desktopMenuTemplate() { function updateApplicationMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(desktopMenuTemplate())); - if (process.platform === "win32" && mainWindow && !mainWindow.isDestroyed()) mainWindow.setMenuBarVisibility(false); + if ((process.platform === "win32" || process.platform === "linux") && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setMenuBarVisibility(false); + if (process.platform === "linux") mainWindow.webContents.send("pilotdeck:application-menu-updated"); + } +} + +function activateLinuxMenuRole(role: string, owner: BrowserWindow): void { + const contents = owner.webContents; + switch (role) { + case "undo": contents.undo(); break; + case "redo": contents.redo(); break; + case "cut": contents.cut(); break; + case "copy": contents.copy(); break; + case "paste": contents.paste(); break; + case "selectAll": contents.selectAll(); break; + case "resetZoom": contents.setZoomLevel(0); break; + case "zoomIn": contents.setZoomLevel(contents.getZoomLevel() + 1); break; + case "zoomOut": contents.setZoomLevel(contents.getZoomLevel() - 1); break; + case "togglefullscreen": owner.setFullScreen(!owner.isFullScreen()); break; + case "reload": contents.reload(); break; + case "close": owner.close(); break; + case "quit": void lifecycle.requestQuit(); break; + case "about": void dialog.showMessageBox(owner, { + type: "info", title: "PilotDeck", message: "PilotDeck", detail: app.getVersion(), + }); break; + } } +ipcMain.handle("pilotdeck:linux-menu-items", (event, id: unknown) => { + requireCaptionSender(event); + return process.platform === "linux" ? linuxCaptionEntries(desktopMenuTemplate(), id) : []; +}); + +ipcMain.handle("pilotdeck:linux-menu-activate", (event, request?: { id?: unknown; index?: unknown }) => { + requireCaptionSender(event); + if (process.platform !== "linux" || !mainWindow || mainWindow.isDestroyed()) return false; + const item = linuxCaptionAction(desktopMenuTemplate(), request?.id, request?.index); + if (!item) return false; + if (item.role) activateLinuxMenuRole(item.role, mainWindow); + else (item.click as (() => void) | undefined)?.(); + return true; +}); + function publishWindowState(): void { if (!mainWindow || mainWindow.isDestroyed()) return; const dark = nativeTheme.shouldUseDarkColors; const fullscreen = mainWindow.isFullScreen(); const palette = windowPalette(dark); mainWindow.setBackgroundColor(palette.background); - // Updating the native overlay while fullscreen can restore the Windows frame. - if (process.platform === "win32" && !fullscreen) mainWindow.setTitleBarOverlay({ + // Updating the native overlay while fullscreen can restore the window frame. + if ((process.platform === "win32" || process.platform === "linux") && !fullscreen) mainWindow.setTitleBarOverlay({ color: palette.caption, symbolColor: palette.symbol, height: WINDOWS_CAPTION_HEIGHT, }); mainWindow.webContents.send("pilotdeck:window-state", { dark, fullscreen }); @@ -1114,7 +1156,8 @@ if (process.platform === "win32") { const lifecycle = createDesktopLifecycle({ platform: process.platform, - shouldConfirm: () => app.isReady() && (process.platform === "darwin" || process.platform === "win32"), + shouldConfirm: () => app.isReady() && (process.platform === "darwin" || process.platform === "win32" + || (process.platform === "linux" && Boolean(mainWindow && !mainWindow.isDestroyed()))), canHide: () => process.platform === "darwin" || Boolean(desktopTray?.available()), isQuitting: () => isQuitting, setQuitting: value => { isQuitting = value; }, @@ -1133,7 +1176,7 @@ const lifecycle = createDesktopLifecycle({ reportStopError: error => dialog.showErrorBox(startupText("PilotDeck could not stop", readAppearance().language), String(error)), }); -const ownsInstance = process.platform !== "win32" || app.requestSingleInstanceLock(); +const ownsInstance = process.platform === "darwin" || app.requestSingleInstanceLock(); if (!ownsInstance) { app.quit(); } else { @@ -1142,7 +1185,7 @@ if (!ownsInstance) { }); app.whenReady() .then(async () => { - if (process.platform === "win32" || process.platform === "darwin") { + if (process.platform === "win32" || process.platform === "darwin" || process.platform === "linux") { desktopTray = createDesktopTray({ platform: process.platform, createTray: () => { @@ -1155,9 +1198,14 @@ if (!ownsInstance) { image.setTemplateImage(true); return new Tray(image); } - const icon = resolveAppIcon(); - if (!icon) throw new Error("PilotDeck tray icon is missing"); - return new Tray(icon); + const iconPath = resolveAppIcon(); + if (!iconPath) throw new Error("PilotDeck tray icon is missing"); + if (process.platform === "linux") { + const image = nativeImage.createFromPath(iconPath).resize({ width: 24, height: 24 }); + if (image.isEmpty()) throw new Error("PilotDeck tray icon is invalid"); + return new Tray(image); + } + return new Tray(iconPath); }, buildMenu: items => Menu.buildFromTemplate(items), isChinese: () => readAppearance().language === "zh-CN", diff --git a/apps/desktop/src/preloadChrome.ts b/apps/desktop/src/preloadChrome.ts index 7ccab8ffa..04d38b969 100644 --- a/apps/desktop/src/preloadChrome.ts +++ b/apps/desktop/src/preloadChrome.ts @@ -1,12 +1,13 @@ import type { IpcRenderer } from 'electron'; import { MAC_CAPTION_HEIGHT, WINDOWS_CAPTION_HEIGHT, WINDOWS_MENUS } from './windowChrome'; +import { createLinuxCaptionPopup } from './linuxCaptionPopup'; /** Desktop-owned caption exists on loading, sign-in, settings and error pages too. */ export function installWindowChrome( platform: NodeJS.Platform, ipc: Pick, ): void { - if (platform !== 'darwin' && platform !== 'win32') return; + if (platform !== 'darwin' && platform !== 'win32' && platform !== 'linux') return; const install = () => { const root = document.documentElement; const appearance = ipc.sendSync('pilotdeck:get-appearance'); @@ -23,7 +24,7 @@ export function installWindowChrome( width:100%; display:block; background:var(--desktop-caption-bg); color:var(--desktop-caption-fg); -webkit-app-region:drag; user-select:none; } :host([hidden]) { display:none; } - :host([data-platform="win32"]) { left:env(titlebar-area-x,0px); width:env(titlebar-area-width,calc(100% - 150px)); } + :host([data-platform="win32"]),:host([data-platform="linux"]) { left:env(titlebar-area-x,0px); width:env(titlebar-area-width,calc(100% - 150px)); } :host([data-integrated]) { width:var(--desktop-sidebar-width); box-sizing:border-box; border-right:1px solid var(--desktop-caption-border); } nav { display:flex; align-items:center; height:100%; padding:0 8px; gap:2px; } button { -webkit-app-region:no-drag; padding:0 12px; height:calc(100% - 8px); max-height:32px; @@ -44,18 +45,21 @@ export function installWindowChrome( nav.append(button); return button; }); - if (platform === 'win32') shadow.append(nav); + if (platform !== 'darwin') shadow.append(nav); let menuOpen = false; let activeMenu: string | undefined; + let hoverOpenedId: string | undefined; let requestSerial = 0; const setActiveMenu = (id?: string) => { activeMenu = id; menuOpen = id !== undefined; buttons.forEach(button => button.setAttribute('aria-expanded', String(button.dataset.menu === id))); }; - ipc.on('pilotdeck:caption-menu', (_event, id: string | null) => setActiveMenu(id ?? undefined)); - const openMenu = async (button?: HTMLButtonElement) => { + const linuxPopup = platform === 'linux' ? createLinuxCaptionPopup(root, buttons, ipc, setActiveMenu) : null; + if (!linuxPopup) ipc.on('pilotdeck:caption-menu', (_event, id: string | null) => setActiveMenu(id ?? undefined)); + const openMenu = async (button?: HTMLButtonElement, toggle = false, focusFirst = false) => { const id = button?.dataset.menu ?? 'all'; + if (linuxPopup) { await linuxPopup.open(button?.dataset.menu ?? WINDOWS_MENUS[0].id, toggle, focusFirst); return; } if (menuOpen && activeMenu === id) return; const serial = ++requestSerial; setActiveMenu(id); @@ -71,8 +75,18 @@ export function installWindowChrome( // Preserve the input selection for native editing actions from the popup. buttons.forEach((button, index) => { button.addEventListener('pointerdown', event => event.preventDefault()); - button.addEventListener('click', () => { void openMenu(button); }); - button.addEventListener('mouseenter', () => { if (menuOpen) void openMenu(button); }); + button.addEventListener('click', () => { + if (linuxPopup && hoverOpenedId === button.dataset.menu) { hoverOpenedId = undefined; return; } + hoverOpenedId = undefined; + void openMenu(button, true); + }); + button.addEventListener('mouseenter', () => { + if (menuOpen && activeMenu !== button.dataset.menu) { + if (linuxPopup) hoverOpenedId = button.dataset.menu; + void openMenu(button); + } + }); + button.addEventListener('mouseleave', () => { if (hoverOpenedId === button.dataset.menu) hoverOpenedId = undefined; }); button.addEventListener('keydown', event => { if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { event.preventDefault(); @@ -80,14 +94,14 @@ export function installWindowChrome( next.focus(); if (menuOpen) void openMenu(next); } else if (event.key === 'ArrowDown') { - event.preventDefault(); void openMenu(button); + event.preventDefault(); void openMenu(button, false, true); } }); }); window.addEventListener('keydown', event => { - if (platform !== 'win32' || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing) return; + if (platform === 'darwin' || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing) return; if (event.key === 'F10' && !event.altKey) { - event.preventDefault(); void openMenu(); + event.preventDefault(); void openMenu(platform === 'linux' ? buttons[0] : undefined, false, platform === 'linux'); } else if (event.altKey) { const index = WINDOWS_MENUS.findIndex(menu => menu.key === event.key.toLowerCase()); if (index >= 0) { event.preventDefault(); void openMenu(buttons[index]); } @@ -99,8 +113,8 @@ export function installWindowChrome( --desktop-caption-bg:#fbfaff; --desktop-caption-fg:#262626; --desktop-bg:#fff; --desktop-caption-border:#e2dff3; --desktop-top-inset:var(--desktop-caption-height); } html[data-desktop-dark] { --desktop-caption-bg:#0a0a0a; --desktop-caption-fg:#e5e5e5; --desktop-bg:#0a0a0a; --desktop-caption-border:#262626; } - html[data-desktop-platform="win32"] { --desktop-caption-height:env(titlebar-area-height,${WINDOWS_CAPTION_HEIGHT}px); --desktop-caption-bg:#f4f4f5; } - html[data-desktop-platform="win32"][data-desktop-dark] { --desktop-caption-bg:#171717; } + html[data-desktop-platform="win32"],html[data-desktop-platform="linux"] { --desktop-caption-height:env(titlebar-area-height,${WINDOWS_CAPTION_HEIGHT}px); --desktop-caption-bg:#f4f4f5; } + html[data-desktop-platform="win32"][data-desktop-dark],html[data-desktop-platform="linux"][data-desktop-dark] { --desktop-caption-bg:#171717; } html[data-desktop-fullscreen] { --desktop-caption-height:0px; } html[data-desktop-integrated] { --desktop-top-inset:0px; } html[data-desktop-platform] body { background:var(--desktop-bg); } @@ -111,6 +125,7 @@ export function installWindowChrome( const integrated = root.hasAttribute('data-desktop-integrated'); host.toggleAttribute('data-integrated', integrated); host.hidden = root.hasAttribute('data-desktop-fullscreen'); + if (host.hidden) linuxPopup?.close(); const zh = root.lang.startsWith('zh'); nav.setAttribute('aria-label', zh ? '应用菜单' : 'Application menu'); buttons.forEach((button, index) => { @@ -118,6 +133,7 @@ export function installWindowChrome( button.textContent = zh ? menu.zh : menu.en; button.title = `${button.textContent} (Alt+${menu.key.toUpperCase()})`; }); + linuxPopup?.refresh(); }; new MutationObserver(update).observe(root, { attributes: true, attributeFilter: ['data-desktop-integrated', 'data-desktop-fullscreen', 'lang'] }); const applyState = (state: { fullscreen: boolean; dark: boolean }) => { diff --git a/apps/desktop/src/updates.ts b/apps/desktop/src/updates.ts index 1585ecda5..b8507c5c5 100644 --- a/apps/desktop/src/updates.ts +++ b/apps/desktop/src/updates.ts @@ -15,11 +15,13 @@ export type UpdateState = { const busyStates = new Set(["checking", "downloading", "verifying", "installing", "recovering"]); export function selectUpdateAssets(release: Release, platform: string, arch: string) { - if (!((platform === "darwin" && ["arm64", "x64"].includes(arch)) || (platform === "win32" && arch === "x64"))) return null; - const extension = platform === "darwin" ? ".zip" : "-setup.exe"; + if (!((platform === "darwin" && ["arm64", "x64"].includes(arch)) || (platform === "win32" && arch === "x64") + || (platform === "linux" && ["arm64", "x64"].includes(arch)))) return null; + const extension = platform === "darwin" ? ".zip" : platform === "linux" ? ".deb" : "-setup.exe"; const packages = release.assets.filter((asset) => asset.platform === platform && asset.arch === arch && asset.name.endsWith(extension) && /^[a-f0-9]{64}$/.test(asset.sha256) && /^[A-Za-z0-9+/]{86}==$/.test(asset.sha512 || "") && asset.size > 0); - const feed = `latest-${arch}${platform === "darwin" ? "-mac" : ""}.yml`; + const feed = platform === "linux" ? `latest-linux${arch === "arm64" ? "-arm64" : ""}.yml` + : `latest-${arch}${platform === "darwin" ? "-mac" : ""}.yml`; return packages.length === 1 && release.assets.some((asset) => asset.name === feed) ? { asset: packages[0], feed } : null; } @@ -126,7 +128,8 @@ export function createUpdateController(options: { if (!checked.canDownload || !checked.latest) throw new Error(checked.reason || "upToDate"); const release = checked.latest; state = { ...state, version: release.tagName }; - updater.setFeedURL({ provider: "generic", url: `https://github.com/${options.repository}/releases/download/${release.tagName}/`, channel: `latest-${options.arch}`, useMultipleRangeRequest: false }); + // Linux's provider appends -linux[-arm64] to the channel itself. + updater.setFeedURL({ provider: "generic", url: `https://github.com/${options.repository}/releases/download/${release.tagName}/`, channel: options.platform === "linux" ? "latest" : `latest-${options.arch}`, useMultipleRangeRequest: false }); const result = await updater.checkForUpdates(); if (cancelled) throw new Error("cancelled"); if (!result || !result.isUpdateAvailable) throw new Error("upToDate"); diff --git a/apps/desktop/src/windowChrome.ts b/apps/desktop/src/windowChrome.ts index c0d6f0580..9b9e58b26 100644 --- a/apps/desktop/src/windowChrome.ts +++ b/apps/desktop/src/windowChrome.ts @@ -18,14 +18,14 @@ export const WINDOWS_MENUS = [ ] as const; export function windowPalette(dark: boolean, platform: NodeJS.Platform = process.platform) { - return { background: dark ? '#0a0a0a' : '#ffffff', caption: platform === 'win32' ? (dark ? '#171717' : '#f4f4f5') : (dark ? '#0a0a0a' : '#fbfaff'), symbol: dark ? '#e5e5e5' : '#262626' }; + return { background: dark ? '#0a0a0a' : '#ffffff', caption: platform === 'win32' || platform === 'linux' ? (dark ? '#171717' : '#f4f4f5') : (dark ? '#0a0a0a' : '#fbfaff'), symbol: dark ? '#e5e5e5' : '#262626' }; } export function windowChromeOptions(platform: NodeJS.Platform, dark: boolean): BrowserWindowConstructorOptions { const palette = windowPalette(dark, platform); return { backgroundColor: palette.background, ...(platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 } } : {}), - ...(platform === 'win32' ? { titleBarStyle: 'hidden', titleBarOverlay: { + ...(platform === 'win32' || platform === 'linux' ? { titleBarStyle: 'hidden', titleBarOverlay: { height: WINDOWS_CAPTION_HEIGHT, color: palette.caption, symbolColor: palette.symbol, } } : {}), }; diff --git a/docs/release.md b/docs/release.md index ef610662a..f174fe807 100644 --- a/docs/release.md +++ b/docs/release.md @@ -38,8 +38,9 @@ known baseline failures; all other UI/server tests remain in the merge gate. the latest unified release tag: - no production change: skip the release; -- production change: build signed and notarized macOS arm64 and x64 installers - plus an unsigned Windows installer, then publish one dated GitHub Release; +- production change: build signed and notarized macOS arm64 and x64 installers, + an unsigned Windows installer, and Ubuntu x64 and arm64 DEBs, then publish one + dated GitHub Release; - repeated manual release on the same date: use `-r2`, `-r3`, and so on. If the first Daily Release attempt fails only during builds, @@ -67,7 +68,8 @@ Each release shares one exact `main` commit across the tag, desktop installers, and Web source code: - Assets: macOS arm64 and x64 DMGs and update ZIPs, the Windows installer, - architecture-specific update feeds, `release.json`, and `SHA256SUMS.txt`. + Ubuntu x64 and arm64 DEBs, architecture-specific update feeds, `release.json`, + and `SHA256SUMS.txt`. - Web source: GitHub's automatically provided **Source code (zip)** and **Source code (tar.gz)** archives for the release tag. No separate Web archive or prebuilt deployment package is uploaded; source deployments still install @@ -116,11 +118,13 @@ Automatic update selection requires an exact platform and running-client archite | macOS arm64 | arm64 ZIP | `latest-arm64-mac.yml` | | macOS x64 (including Rosetta) | x64 ZIP | `latest-x64-mac.yml` | | Windows x64 | x64 setup EXE | `latest-x64.yml` | +| Ubuntu x64 | x64 DEB | `latest-linux.yml` | +| Ubuntu arm64 | arm64 DEB | `latest-linux-arm64.yml` | DMGs remain available for initial Mac installation. Each Mac build produces its own feed, renamed before artifact upload so the matrix jobs cannot overwrite one another's metadata. Each feed contains only its own architecture. CI verifies all -three feeds and both ZIPs before publishing. Full downloads are used initially; +five feeds, both ZIPs and both DEBs before publishing. Full downloads are used initially; blockmaps and differential updates are not required. **Update and restart** is one explicit user action. The Electron main process @@ -133,6 +137,9 @@ updater to install and relaunch the application. macOS performs native signature verification; Windows may show an administrator approval prompt because our NSIS installer is per-machine. Both the interactive installer and silent `--force-run` updates launch via the existing `explorer.exe` workaround. +On Ubuntu, the DEB updater asks for PolicyKit authorization to install the +verified package with `dpkg`, then relaunches. The client does not install +updates automatically on quit. Before installation, runtime shutdown confirms that managed descendants exited; a failed stop aborts installation and retains process records for recovery. @@ -159,12 +166,14 @@ Closing the client normally does not automatically install a cached update. Unpackaged development clients cannot install updates. This capability starts with a client built from this implementation. Clients -that only open DMG/EXE installers need to install this version once before later +that only open DMG/EXE installers, and the earlier Ubuntu `0.1.0` test packages, +need to install this version once before later releases can update automatically. A release without the required update feed or matching payload disables the action with an explanation. Changes to this flow require a real old-version-to-new-version install test on -macOS arm64, macOS x64 (including Rosetta), and Windows x64. Unit and packaging +macOS arm64, macOS x64 (including Rosetta), Windows x64, Ubuntu x64, and Ubuntu +arm64. Unit and packaging tests alone do not establish that signing, elevation, replacement and relaunch work on those systems. @@ -202,6 +211,9 @@ pnpm --filter pilotdeck-desktop dist:mac:arm64 pnpm --filter pilotdeck-desktop dist:mac:x64 # Run the following on Windows: pnpm --filter pilotdeck-desktop dist:win +# On native Ubuntu 22.04 hosts with matching architectures: +pnpm --filter pilotdeck-desktop dist:linux:x64 +pnpm --filter pilotdeck-desktop dist:linux:arm64 ``` Local macOS builds can use ad-hoc signing. Set @@ -220,9 +232,9 @@ UI manifests, or if its committed lockfile is stale. ## Recovery If one architecture or platform fails, fix the credential or build issue and -rerun the failed workflow. A release is created only after both macOS DMGs and -the Windows installer are downloaded and verified. For a deliberate additional -release on the same Shanghai date, leave revision empty to select the next +rerun the failed workflow. A release is created only after both macOS DMGs, the +Windows installer, and both Ubuntu DEBs are downloaded and verified. For a +deliberate additional release on the same Shanghai date, leave revision empty to select the next available `-rN` tag automatically. ## Update regression smoke checks @@ -252,6 +264,15 @@ they do not publish a Release. It does not run the generated EXE. Windows elevation/relaunch and signed macOS cross-version replacement still require real platform upgrade tests. +Desktop Smoke and Daily Release also share the Ubuntu DEB workflow. Native +Ubuntu 22.04 x64 and arm64 runners each build and install their DEB, validate +the launcher icon and bundled native modules, then start the installed app under +Xvfb/X11 and headless Weston/Wayland. The smoke waits for a responsive Web UI; +the X11 check also verifies that an application window exists. A failed matrix +job blocks its PR check or the daily release. These headless checks do not +exercise a full GNOME session, top-panel indicator interaction, PolicyKit +authorization, or a published-release update and relaunch. + ## Managed process shutdown Only explicitly launched desktop services and Web update build commands use the diff --git a/docs/ubuntu-desktop-support.md b/docs/ubuntu-desktop-support.md new file mode 100644 index 000000000..7eb704248 --- /dev/null +++ b/docs/ubuntu-desktop-support.md @@ -0,0 +1,168 @@ +# Ubuntu desktop support assessment + +Status: local validation on 2026-09-29. This branch adds Linux DEB builds to +the release workflow and wires the desktop update action to the DEB updater. +The public Latest Release still has no Linux assets until this branch is merged +and a new release completes. +The `0.1.0` Ubuntu test packages predate this updater and must be upgraded +manually once. Releases built from this branch can update later releases. + +## Target + +- Minimum: Ubuntu 22.04 LTS, x86_64 and arm64. Build each architecture on a + native Ubuntu 22.04 runner so bundled native modules do not acquire a newer + glibc requirement. The 22.04 user space uses glibc 2.35. The build command + rejects other Ubuntu versions. + GitHub plans to retire its hosted 22.04 runner images on 2027-04-17; before + then, move the 22.04 build user space to containers on newer native hosts or + provide native 22.04 self-hosted runners while keeping the same build-host + and packaged-binary compatibility checks. +- Package: one `.deb` per architecture. Ubuntu's package manager can install the + declared desktop libraries and system Git, register the desktop launcher, + and handle uninstall. The x86_64 package filename uses Debian's `amd64` + architecture name; its update feed is `latest-linux.yml`. A portable AppImage + can be evaluated later if users need other distributions; Snap and Flatpak + need separate validation of local files, terminals, child processes, and + browser downloads. +- Other Debian-family desktops: the window menu popup uses Electron's renderer + and the current application menu template. It has no Yaru, GNOME Shell, + AppIndicators, GTK theme, or distro-specific dependency. This is the shared + implementation for Kylin, Deepin and UOS too. Installing the DEB still + requires a compatible `amd64` or `arm64` system, glibc 2.35 or newer, and + the declared system libraries. A top-panel tray icon additionally requires + a status-icon host supplied by the desktop environment. Actual distro and + version support must be established with installation and desktop tests; + the Ubuntu validation below does not certify every Kylin, Deepin or UOS + release. +- Display: support both X11/Xorg and native Wayland. Electron 42's Ozone + selection defaults to `auto` on Linux. On a Wayland session, users can force + XWayland with `pilotdeck-desktop --ozone-platform=x11` if a compositor-specific + problem occurs. Do not force X11 for everyone. +- Background: closing the window hides it when the desktop tray is available. + Ubuntu's top-panel AppIndicators host shows the menu to reopen or quit; + launching the application again also restores the running window. If the + tray cannot be created, closing the last window exits normally. + +## Local validation + +Both `.deb` packages were built in isolated Ubuntu 22.04 user spaces with glibc +2.35. The arm64 user space ran natively on the Ubuntu arm64 VM; the amd64 user +space ran through QEMU user emulation on that VM. Both packages installed with +`apt`, passed `desktop-file-validate`, and ran bundled Node.js plus +`better-sqlite3`, `bcrypt`, `sharp`, and `node-pty` (including a PTY spawn). +The packaged Electron executable, bundled Node.js, and Linux `.node` files have +no GLIBC symbol requirement above 2.34. + +For the updater, both Ubuntu 22.04 user spaces built local `2026.928.0` +packages from this branch. Their packaged `electron-updater` modules loaded +successfully; `latest-linux.yml` and `latest-linux-arm64.yml` matched the +respective DEBs' sizes and SHA-512 hashes. The updater's DEB install command +upgraded an installed `0.1.0` package to `2026.928.0` with `dpkg` on both +architectures (amd64 under QEMU). The live Latest Release check correctly +reports `noCompatibleInstaller` on both Linux architectures because the +published release has no Linux assets. The full HTTPS download, desktop +PolicyKit dialog, and automatic relaunch await a published Linux release. + +The installed arm64 package now shows its native File/Edit/View/Go/Help menu +under both Xorg and Wayland; an Xorg mouse click opened the File dropdown. +The launcher icon is installed at `hicolor/256x256/apps`, and GTK resolved +`pilotdeck-desktop` to that PNG in both the 22.04 user space and the full +Ubuntu 26.04 GNOME VM. The full VM showed the icon in the Dock and the Chinese +application menu. The earlier `1024x1024` icon path was absent from Jammy's +hicolor theme index. +The installer now keeps the `.desktop` launcher readable even when the build +shell has a restrictive umask. A normal VM user launched the rebuilt DEB from +its desktop entry, and the Dock displayed the PilotDeck icon. The Linux package +smoke explicitly checks launcher and icon read permissions. + +The `2026.928.1` arm64 test DEB was installed in the full Ubuntu 26.04 GNOME +Wayland VM. Its PilotDeck indicator appeared in the top panel beside the +network icon, and its right-click menu showed **Open main window** and **Quit** +in Chinese. A left click on Ubuntu's AppIndicator also opened this menu. +Closing the main window left the indicator, Electron process, and +managed Node process running. The menu restored the same window. Quit showed +the localized confirmation; Cancel kept the app running, and confirming Quit +removed the process and indicator. The same indicator interaction still needs +a full Xorg GNOME session check on Ubuntu 22.04. + +The same installed test DEB was then started with `--ozone-platform=x11` in +that GNOME Wayland session. Its process and renderer reported the X11 Ozone +path, and the window used XWayland. Closing the window kept the top-panel icon +visible; its right-click menu remained accessible, and **Open main window** +restored the window. **Quit** showed the same confirmation dialog, and +confirming it removed the Electron process and status icon. This checks the +XWayland path, not a full Xorg login. + +A new arm64 DEB built in the Ubuntu 22.04 user space was opened in the full +Ubuntu 26.04 GNOME Wayland session. Its native window controls and the five +application menus share one 40px title-bar row; the separate system title bar +and menu row are gone. The same layout rendered through forced XWayland. A +saved Chinese and dark appearance showed Chinese menu captions and matching +dark title-bar controls. An initial native GTK File popup stayed light when the +app was dark and the system GTK theme was light. Changing GTK_THEME after +startup did not update GTK. Linux now renders the popup in the application +window from the same current menu template, while Electron keeps the native +menu registered for accelerators. This avoids an environment-specific theme +override. The isolated Electron chrome smoke suite passed under Ubuntu 22.04 +Xvfb/X11 and headless Weston/Wayland, checking all five captions, menu action +dispatch, keyboard navigation, Chinese/English menu entries, and immediate +light/dark popup changes. The headless 22.04 environment lacks Chinese fonts, +so visual glyph acceptance relies on the full GNOME session; these tests do +not replace the remaining full 22.04 desktop checks below. + +The rebuilt arm64 DEB was reinstalled in the Ubuntu 22.04 user space after the +popup change. The launcher, icon, bundled native modules and packaged updater +passed validation; the installed app started and served its UI under both X11 +and Wayland. The full Ubuntu 26.04 GNOME VM retained its installed test app; +opening a second isolated app instance made Parallels' screen capture black, +so a full GNOME visual check of the new popup is still pending. The interactive +popup was visually inspected from both X11 and Wayland smoke screenshots. + +| Environment | Display path | Result | +| --- | --- | --- | +| Ubuntu 22.04 arm64 user space | Xvfb/X11, `--ozone-platform=x11` | Installed app rendered onboarding in an X11 screenshot; local HTTP server returned 200. | +| Ubuntu 22.04 arm64 user space | Xorg 1.21 with the dummy video driver, `--ozone-platform=x11` | Installed app rendered onboarding in an Xorg screenshot; local HTTP server returned 200. | +| Ubuntu 22.04 arm64 user space | Weston 9 headless native Wayland, forced and default Ozone selection | Installed app rendered onboarding in compositor screenshots; local HTTP server returned 200. | +| Ubuntu 22.04 amd64 user space under QEMU | Xvfb/X11, with and without disabled GPU flags | Package and native modules passed, but emulated Electron failed to start its GPU process and exited. This does not validate native x64 display support. | + +These user spaces use the Ubuntu 26.04 VM kernel and are not full Ubuntu 22.04 +GNOME login sessions. The Xorg dummy driver and headless Weston have no GPU +access; Weston has no keyboard seat. The minimal font set lacks Chinese glyphs. +Full 22.04 desktop acceptance is still required, especially native amd64 Xorg +and Wayland. + +Earlier validation used an arm64 package built on Ubuntu 24.04: it rendered on +X11 and Weston Wayland there, and on native Wayland, XWayland, and Xvfb/X11 in +the full Ubuntu 26.04 arm64 GNOME VM. That package is separate from the 22.04 +builds above. An arm64 package built on Ubuntu 26.04 required `GLIBC_2.42` in +`node-pty`, demonstrating why newer build hosts cannot establish 22.04 support. + +## Before enabling Linux releases + +1. The release workflow builds on `ubuntu-22.04` (x64) and `ubuntu-22.04-arm` + (arm64). The same reusable workflow runs before PR merge and during Daily + Release. Each job checks the `.deb` architecture, updater feed, installed + launcher icon and native modules, then starts the installed app under + Xvfb/X11 and headless Weston/Wayland. Both architecture jobs and both Linux + feeds must pass before publishing. Confirm a completed workflow on the production + repository; local builds cannot validate hosted runner behavior. +2. On full Ubuntu 22.04 desktop installations, test both an Xorg login and a + Wayland login for each architecture. Install with `apt`, launch from the + application menu, then test the onboarding window, resize/close/reopen, + clipboard, file dialogs, terminal/PTY, tray/menu behavior, and clean exit. + Repeat native Wayland and forced XWayland on the Wayland login. + Before claiming Kylin, Deepin or UOS support for a specific release, + repeat installation, menu, theme, tray, terminal and updater checks on that + release and both CPU architectures. Their shell panels and system package + names may differ from Ubuntu's. +3. Install an older release and exercise the full check, download, checksum, + PolicyKit authorization, `dpkg` upgrade, and relaunch path against a newer + published release on both architectures. The updater requires an explicit + **Update and restart** action; closing the application does not install it. + +References: [Electron Ozone behavior](https://www.electronjs.org/docs/latest/breaking-changes/), +[Electron Wayland window notes](https://www.electronjs.org/docs/latest/api/browser-window), +[electron-builder v26 Linux targets](https://www.electron.build/v26/docs/linux/), +[electron-updater v26 DEB support](https://www.electron.build/v26/docs/features/auto-update/), +[GitHub hosted runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), +[Ubuntu 22.04 runner retirement](https://github.com/actions/runner-images/issues/14254). diff --git a/ui/e2e/desktop-chrome.smoke.mjs b/ui/e2e/desktop-chrome.smoke.mjs index ea7e32cb2..d60fa4d44 100644 --- a/ui/e2e/desktop-chrome.smoke.mjs +++ b/ui/e2e/desktop-chrome.smoke.mjs @@ -8,13 +8,16 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const mac = process.platform === 'darwin'; -const platformName = mac ? 'mac' : 'windows'; +const platformName = mac ? 'mac' : process.platform === 'linux' ? 'linux' : 'windows'; const artifactDir = process.env.PILOTDECK_CHROME_ARTIFACTS || path.join(root, 'outputs/desktop-chrome-review'); await fs.mkdir(artifactDir, { recursive: true }); const profile = await fs.mkdtemp(path.join(os.tmpdir(), 'pilotdeck-chrome-')); const app = await electron.launch({ executablePath: createRequire(import.meta.url)(path.join(root, 'apps/desktop/node_modules/electron')), - args: [path.join(root, 'apps/desktop/scripts/fixtures/desktop-chrome.cjs'), ...(process.env.PILOTDECK_CHROME_SCALE ? [`--force-device-scale-factor=${process.env.PILOTDECK_CHROME_SCALE}`] : [])], + args: [path.join(root, 'apps/desktop/scripts/fixtures/desktop-chrome.cjs'), + ...(process.env.PILOTDECK_CHROME_SCALE ? [`--force-device-scale-factor=${process.env.PILOTDECK_CHROME_SCALE}`] : []), + ...(process.env.PILOTDECK_CHROME_OZONE ? [`--ozone-platform=${process.env.PILOTDECK_CHROME_OZONE}`] : []), + ...(process.env.PILOTDECK_CHROME_DISABLE_SANDBOX ? ['--no-sandbox'] : [])], env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'ELECTRON_RUN_AS_NODE')), PILOTDECK_CHROME_PROFILE: profile }, }); app.process().on('exit', (code, signal) => { if (code) console.error('Electron exited', { code, signal }); }); @@ -90,24 +93,63 @@ try { await page.screenshot({ path: path.join(artifactDir, `${platformName}-dark.png`) }); if (!mac) { expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMenuBarVisible())).toBe(false); + expect(await app.evaluate(({ nativeTheme }) => nativeTheme.shouldUseDarkColors)).toBe(true); const caption = page.locator('#pilotdeck-window-caption'); await expect(caption.locator('button')).toHaveCount(5); await expect(caption).toHaveCSS('background-color', 'rgb(23, 23, 23)'); await expect(page.locator('.project-sidebar')).toHaveCSS('background-color', 'rgb(10, 10, 10)'); - // CDP keyboard events cannot dismiss a Win32 popup; close it via Electron. + // Win32 owns its popup; Linux draws the same menu template in the preload + // so it follows the app theme on GTK desktops with different system themes. await caption.getByRole('button', { name: 'File', exact: true }).click(); await expect.poll(() => app.evaluate(() => global.chromeTest.menuRequests().at(-1)?.id)).toBe('menu-file'); + const linuxPopup = page.locator('#pilotdeck-linux-popup'); + if (process.platform === 'linux') { + await expect(linuxPopup).toBeVisible(); + await expect(linuxPopup.locator('.panel')).toHaveCSS('background-color', 'rgb(37, 37, 37)'); + await expect(linuxPopup.locator('[data-action="new-project"]')).toContainText('New Project'); + await page.screenshot({ path: path.join(artifactDir, `${platformName}-dark-popup.png`) }); + for (const [key, selector, label] of [ + ['f', '[data-action="new-project"]', 'New Project'], + ['e', '[data-index="0"]', 'Undo'], + ['v', '[data-action="toggle-sidebar"]', 'Show Sidebar'], + ['g', '[data-action="chat"]', 'Conversation'], + ['h', '[data-action="help-docs"]', 'Documentation'], + ]) { + await page.keyboard.press('Escape'); + await page.keyboard.press(`Alt+${key}`); + await expect(linuxPopup).toBeVisible(); + await expect(linuxPopup.locator('.panel')).toHaveCSS('background-color', 'rgb(37, 37, 37)'); + await expect(linuxPopup.locator(selector)).toContainText(label); + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMenuBarVisible())).toBe(false); + } + } await caption.getByRole('button', { name: 'Edit', exact: true }).hover(); await expect(caption.getByRole('button', { name: 'Edit', exact: true })).toHaveAttribute('aria-expanded', 'true'); + if (process.platform === 'linux') await expect(linuxPopup.locator('.entry')).toContainText(['Undo', 'Redo', 'Cut', 'Copy', 'Paste', 'Select All', 'Find…']); await caption.getByRole('button', { name: 'View', exact: true }).click(); await expect(caption.getByRole('button', { name: 'View', exact: true })).toHaveAttribute('aria-expanded', 'true'); // Product state can rebuild the application menu while its popup is open. await app.evaluate(() => global.chromeTest.refreshMenu()); - await app.evaluate(() => global.chromeTest.closeMenu()); + if (process.platform === 'linux') { + await expect(linuxPopup.locator('[data-action="toggle-sidebar"]')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(linuxPopup).toBeHidden(); + } else await app.evaluate(() => global.chromeTest.closeMenu()); await expect(caption.getByRole('button', { name: 'File', exact: true })).toHaveAttribute('aria-expanded', 'false'); await page.keyboard.press('F10'); - await expect.poll(() => app.evaluate(() => global.chromeTest.menuRequests().at(-1)?.id)).toBe('all'); - await app.evaluate(() => global.chromeTest.closeMenu()); + await expect.poll(() => app.evaluate(() => global.chromeTest.menuRequests().at(-1)?.id)).toBe(process.platform === 'linux' ? 'menu-file' : 'all'); + if (process.platform === 'linux') { + await expect(linuxPopup.locator('.entry:not(:disabled)').first()).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(linuxPopup.locator('.panel')).toHaveAttribute('aria-label', 'Edit'); + await page.keyboard.press('Escape'); + await caption.getByRole('button', { name: 'View', exact: true }).click(); + await linuxPopup.getByRole('menuitem', { name: 'Zoom In' }).click(); + await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.getZoomLevel())).toBe(1); + await caption.getByRole('button', { name: 'View', exact: true }).click(); + await linuxPopup.getByRole('menuitem', { name: 'Actual Size' }).click(); + await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.getZoomLevel())).toBe(0); + } else await app.evaluate(() => global.chromeTest.closeMenu()); } const command = async id => { await expect.poll(() => app.evaluate(({ Menu }, id) => Menu.getApplicationMenu().getMenuItemById(id)?.enabled, id)).toBe(true); @@ -119,7 +161,10 @@ try { console.log('Native review window ready; waiting for manual inspection.'); await new Promise(resolve => setTimeout(resolve, Number(process.env.PILOTDECK_CHROME_MANUAL_WAIT_MS) || 120000)); } - await command('new-project'); + if (process.platform === 'linux') { + await page.locator('#pilotdeck-window-caption').getByRole('button', { name: 'File', exact: true }).click(); + await page.locator('#pilotdeck-linux-popup').locator('[data-action="new-project"]').click(); + } else await command('new-project'); await expect(page.locator('.create-workspace-dialog')).toBeVisible(); await expect.poll(() => app.evaluate(() => global.chromeTest.state().blocked)).toBe(true); await page.keyboard.press('Escape'); @@ -160,6 +205,7 @@ try { await expect(page.locator('.app-shell')).toBeVisible(); await page.evaluate(() => window.pilotdeckDesktop.setAppearance({ language: 'en', themeMode: 'light' })); await expect(page.locator('html')).not.toHaveAttribute('data-desktop-dark'); + expect(await app.evaluate(({ nativeTheme }) => nativeTheme.shouldUseDarkColors)).toBe(false); // ThemeContext consumes the persisted desktop appearance on reload. await page.reload(); await expect(page.locator('.app-shell')).toBeVisible(); @@ -169,24 +215,65 @@ try { await expect(page.locator('#pilotdeck-window-caption')).toHaveCSS('background-color', 'rgb(244, 244, 245)'); await verifyOriginalContentStyle(); await expect(page.locator('.project-sidebar')).toHaveCSS('background-image', 'linear-gradient(rgb(251, 250, 255), rgb(244, 243, 255) 58%, rgb(240, 244, 255))'); + if (process.platform === 'linux') { + const popup = page.locator('#pilotdeck-linux-popup'); + const caption = page.locator('#pilotdeck-window-caption'); + await caption.getByRole('button', { name: 'File', exact: true }).click(); + await expect(popup.locator('.panel')).toHaveCSS('background-color', 'rgb(255, 255, 255)'); + await page.screenshot({ path: path.join(artifactDir, `${platformName}-light-popup.png`) }); + await page.evaluate(() => window.pilotdeckDesktop.setAppearance({ language: 'en', themeMode: 'dark' })); + await expect(popup.locator('.panel')).toHaveCSS('background-color', 'rgb(37, 37, 37)'); + await page.evaluate(() => window.pilotdeckDesktop.setAppearance({ language: 'en', themeMode: 'light' })); + await expect(popup.locator('.panel')).toHaveCSS('background-color', 'rgb(255, 255, 255)'); + } // Exercise the actual i18n instance, including persistence across reload. await page.evaluate(async () => { const { default: i18n } = await import('/src/i18n/config.js'); await i18n.changeLanguage('zh-CN'); }); await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN'); await expect(page.locator('#pilotdeck-window-caption button')).toHaveText(['文件', '编辑', '查看', '前往', '帮助']); await expect.poll(() => app.evaluate(({ Menu }) => Menu.getApplicationMenu().getMenuItemById('menu-file').label)).toBe('文件'); + expect(await app.evaluate(({ Menu }) => [ + Menu.getApplicationMenu().getMenuItemById('new-conversation').label, + Menu.getApplicationMenu().getMenuItemById('help-docs').label, + ])).toEqual(['新对话', '使用文档']); + if (process.platform === 'linux') { + const popup = page.locator('#pilotdeck-linux-popup'); + for (const [id, selector, label] of [ + ['menu-file', '[data-action="new-project"]', '新建项目'], + ['menu-edit', '[data-index="0"]', '撤销'], + ['menu-view', '[data-action="toggle-sidebar"]', '显示侧栏'], + ['menu-go', '[data-action="chat"]', '对话'], + ['menu-help', '[data-action="help-docs"]', '使用文档'], + ]) { + await page.keyboard.press('Escape'); + await page.locator(`#pilotdeck-window-caption button[data-menu="${id}"]`).click(); + await expect(popup.locator('.panel')).toHaveCSS('background-color', 'rgb(255, 255, 255)'); + await expect(popup.locator(selector)).toContainText(label); + } + await page.screenshot({ path: path.join(artifactDir, `${platformName}-light-zh-popup.png`) }); + await page.keyboard.press('Escape'); + await expect(popup).toBeHidden(); + } await page.reload(); await expect(page.locator('.app-shell')).toBeVisible(); await expect(page.locator('#pilotdeck-window-caption button')).toHaveText(['文件', '编辑', '查看', '前往', '帮助']); - await page.screenshot({ path: path.join(artifactDir, 'windows-light-zh.png') }); + await page.screenshot({ path: path.join(artifactDir, `${platformName}-light-zh.png`) }); await page.evaluate(async () => { const { default: i18n } = await import('/src/i18n/config.js'); await i18n.changeLanguage('en'); }); await expect(page.locator('#pilotdeck-window-caption button')).toHaveText(['File', 'Edit', 'View', 'Go', 'Help']); await expect.poll(() => app.evaluate(({ Menu }) => Menu.getApplicationMenu().getMenuItemById('menu-file').label)).toBe('&File'); - const safeArea = await page.locator('#pilotdeck-window-caption').evaluate(el => ({ right: el.getBoundingClientRect().right, width: innerWidth })); - expect(safeArea.right).toBeLessThan(safeArea.width - 100); - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].maximize()); - await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized())).toBe(true); - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].unmaximize()); - await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized())).toBe(false); + expect(await app.evaluate(({ Menu }) => [ + Menu.getApplicationMenu().getMenuItemById('new-conversation').label, + Menu.getApplicationMenu().getMenuItemById('help-docs').label, + ])).toEqual(['New Conversation', 'Documentation']); + const safeArea = await page.locator('#pilotdeck-window-caption').evaluate(el => ({ + left: el.getBoundingClientRect().left, right: el.getBoundingClientRect().right, width: innerWidth, + })); + expect(safeArea.left > 80 || safeArea.right < safeArea.width - 80).toBe(true); + if (process.platform === 'win32') { + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].maximize()); + await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized())).toBe(true); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].unmaximize()); + await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized())).toBe(false); + } } await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setFullScreen(true)); await expect(page.locator('html')).toHaveAttribute('data-desktop-fullscreen', ''); diff --git a/ui/server/services/desktopChrome.test.ts b/ui/server/services/desktopChrome.test.ts index 33885dc56..87a5e7c55 100644 --- a/ui/server/services/desktopChrome.test.ts +++ b/ui/server/services/desktopChrome.test.ts @@ -7,6 +7,7 @@ const ipc = vi.hoisted(() => ({ })); import { installWindowChrome } from '../../../apps/desktop/src/preloadChrome'; import { isRendererEditingShortcut, windowChromeOptions } from '../../../apps/desktop/src/windowChrome'; +import { linuxCaptionAction, linuxCaptionEntries } from '../../../apps/desktop/src/linuxCaptionMenu'; it('reserves only plain editing shortcuts for the renderer, leaving macOS fullscreen native', () => { const input = { key: 'f', meta: true, control: false, shift: false, alt: false, isComposing: false }; @@ -25,7 +26,25 @@ it('uses native caption controls with an opaque matching background on each plat expect(windowChromeOptions('darwin', true)).toMatchObject({ titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, backgroundColor: '#0a0a0a' }); expect(windowChromeOptions('win32', false)).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: 40, color: '#f4f4f5', symbolColor: '#262626' } }); expect(windowChromeOptions('win32', true)).toMatchObject({ titleBarOverlay: { color: '#171717', symbolColor: '#e5e5e5' } }); - expect(windowChromeOptions('linux', false).titleBarStyle).toBeUndefined(); + expect(windowChromeOptions('linux', false)).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: 40, color: '#f4f4f5', symbolColor: '#262626' } }); + expect(windowChromeOptions('linux', true)).toMatchObject({ titleBarOverlay: { color: '#171717', symbolColor: '#e5e5e5' } }); +}); +it('Linux popup accepts only visible enabled actions in known menu sections', () => { + const template = [{ id: 'menu-file', submenu: [ + { id: 'open', label: '打开', accelerator: 'CmdOrCtrl+O' }, + { id: 'disabled', label: '不可用', enabled: false }, + { type: 'separator' as const }, + { id: 'hidden', label: '隐藏', visible: false }, + ] }]; + expect(linuxCaptionEntries(template, 'menu-file')).toMatchObject([ + { index: 0, id: 'open', label: '打开', accelerator: 'Ctrl+O', enabled: true }, + { index: 1, id: 'disabled', enabled: false }, + { index: 2, type: 'separator', enabled: false }, + ]); + expect(linuxCaptionEntries(template, 'unknown')).toEqual([]); + expect(linuxCaptionAction(template, 'menu-file', 0)?.id).toBe('open'); + for (const index of [1, 2, 3, -1, 0.5, '0']) expect(linuxCaptionAction(template, 'menu-file', index)).toBeUndefined(); + expect(linuxCaptionAction(template, 'unknown', 0)).toBeUndefined(); }); it('Windows caption initializes its saved language, follows language/fullscreen changes and opens native menus', async () => { document.documentElement.lang = 'en'; diff --git a/ui/server/services/desktopUpdates.spec.ts b/ui/server/services/desktopUpdates.spec.ts new file mode 100644 index 000000000..1d8eeae3c --- /dev/null +++ b/ui/server/services/desktopUpdates.spec.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; +import { createRequire } from 'node:module'; +import { describe, expect, it, vi } from 'vitest'; +import type { AppUpdater } from 'electron-updater'; +import { createUpdateController, selectUpdateAssets, validateUpdateInfo, type Release } from '../../../apps/desktop/src/updates'; + +const checksum = 'a'.repeat(64); +const sha512 = 'A'.repeat(86) + '=='; +const desktopRequire = createRequire(new URL('../../../apps/desktop/package.json', import.meta.url)); + +function release(platform: string, arch: string, payload: string, feed: string): Release { + return { + version: '2026.928.0', tagName: 'v2026.09.28', + assets: [ + { name: payload, size: 42, sha256: checksum, sha512, platform, arch }, + { name: feed, size: 10, sha256: checksum, sha512, platform, arch }, + ], + }; +} + +describe('desktop update assets', () => { + it('matches the Linux channel suffix used by the installed electron-updater', () => { + const { GenericProvider } = desktopRequire('electron-updater/out/providers/GenericProvider'); + const previous = process.env.TEST_UPDATER_ARCH; + try { + for (const [arch, feed] of [['x64', 'latest-linux'], ['arm64', 'latest-linux-arm64']]) { + process.env.TEST_UPDATER_ARCH = arch; + const provider = new GenericProvider({ url: 'https://example.invalid/' }, { channel: 'latest' }, { platform: 'linux', executor: {} }); + expect(provider.channel).toBe(feed); + } + } finally { + if (previous === undefined) delete process.env.TEST_UPDATER_ARCH; + else process.env.TEST_UPDATER_ARCH = previous; + } + }); + + it.each([ + ['darwin', 'arm64', 'PilotDeck-2026.928.0-mac-arm64.zip', 'latest-arm64-mac.yml'], + ['darwin', 'x64', 'PilotDeck-2026.928.0-mac-x64.zip', 'latest-x64-mac.yml'], + ['win32', 'x64', 'PilotDeck-2026.928.0-win-x64-setup.exe', 'latest-x64.yml'], + ['linux', 'x64', 'PilotDeck-2026.928.0-linux-amd64.deb', 'latest-linux.yml'], + ['linux', 'arm64', 'PilotDeck-2026.928.0-linux-arm64.deb', 'latest-linux-arm64.yml'], + ])('selects the %s %s package and feed', (platform, arch, payload, feed) => { + const latest = release(platform, arch, payload, feed); + expect(selectUpdateAssets(latest, platform, arch)).toMatchObject({ asset: latest.assets[0], feed }); + expect(validateUpdateInfo({ version: latest.version, files: [{ url: payload, sha512, size: 42 }] }, latest, platform, arch)).toEqual(latest.assets[0]); + expect(() => validateUpdateInfo({ version: latest.version, files: [{ url: payload, sha512, size: 43 }] }, latest, platform, arch)).toThrow('invalidUpdateMetadata'); + expect(selectUpdateAssets({ ...latest, assets: latest.assets.slice(0, 1) }, platform, arch)).toBeNull(); + }); + + it('uses the native Linux feed name and installs a verified DEB', async () => { + const latest = release('linux', 'arm64', 'PilotDeck-2026.928.0-linux-arm64.deb', 'latest-linux-arm64.yml'); + const updater = Object.assign(new EventEmitter(), { + setFeedURL: vi.fn(), + checkForUpdates: vi.fn(async () => ({ + isUpdateAvailable: true, + updateInfo: { version: latest.version, files: [{ url: latest.assets[0].name, sha512, size: 42 }] }, + cancellationToken: { cancel: vi.fn() }, + })), + downloadUpdate: vi.fn(async () => ['/tmp/PilotDeck-test.deb']), + quitAndInstall: vi.fn(), + }); + const prepareToInstall = vi.fn(); + const verifyFile = vi.fn(); + const controller = createUpdateController({ + updater: updater as unknown as AppUpdater, repository: 'OpenBMB/PilotDeck', + platform: 'linux', arch: 'arm64', version: '2026.927.0', packaged: true, + latestRelease: async () => latest, + compareVersions: (left, right) => left.localeCompare(right), + prepareToInstall, recoverRuntime: vi.fn(), verifyFile, + }); + expect(await controller.check()).toMatchObject({ hasUpdate: true, canDownload: true }); + controller.start(); + await controller.wait(); + expect(updater.setFeedURL).toHaveBeenCalledWith(expect.objectContaining({ + channel: 'latest', url: 'https://github.com/OpenBMB/PilotDeck/releases/download/v2026.09.28/', + })); + expect(verifyFile).toHaveBeenCalledWith('/tmp/PilotDeck-test.deb', latest.assets[0]); + expect(prepareToInstall).toHaveBeenCalledOnce(); + expect(updater.quitAndInstall).toHaveBeenCalledWith(true, true); + expect(controller.status().state).toBe('installing'); + }); +}); diff --git a/ui/src/components/desktop/desktop.css b/ui/src/components/desktop/desktop.css index f04619a79..8ac55a757 100644 --- a/ui/src/components/desktop/desktop.css +++ b/ui/src/components/desktop/desktop.css @@ -39,16 +39,16 @@ html[data-desktop-platform="darwin"] .desktop-prototype-shell > .onboarding-fram box-shadow: none; } -/* Reserve the Windows caption without replacing the existing application theme, +/* Reserve the Windows/Linux caption without replacing the existing application theme, sidebar gradient, rounded frame or six-pixel viewport gutter. */ -html[data-desktop-platform="win32"] .app-root { +html:is([data-desktop-platform="win32"], [data-desktop-platform="linux"]) .app-root { padding-top: calc(var(--desktop-top-inset) + 6px); } -html[data-desktop-platform="win32"] .desktop-prototype-shell { +html:is([data-desktop-platform="win32"], [data-desktop-platform="linux"]) .desktop-prototype-shell { margin-top: var(--desktop-caption-height); height: calc(100dvh - var(--desktop-caption-height)); } -html[data-desktop-platform="win32"] .workspace-header { +html:is([data-desktop-platform="win32"], [data-desktop-platform="linux"]) .workspace-header { -webkit-app-region: drag; user-select: none; } From 70f2026fda36a5df2b5c1befced7bba0142f317a Mon Sep 17 00:00:00 2001 From: Haidong Xin Date: Tue, 29 Sep 2026 10:02:25 +0800 Subject: [PATCH 2/2] ci(web): exclude desktop updater spec from web-only tests --- .github/workflows/web-regression.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/web-regression.yml b/.github/workflows/web-regression.yml index 39c76cf2c..03048d7df 100644 --- a/.github/workflows/web-regression.yml +++ b/.github/workflows/web-regression.yml @@ -38,12 +38,13 @@ jobs: # and streamSmoother's fake-timer spec is independently flaky. Keep the # rest of the Web suite as the merge gate until those baseline tests are # repaired in their own change. - # Desktop Smoke runs the updater network suite with desktop dependencies. + # Desktop Smoke runs both updater suites with desktop dependencies. run: >- pnpm --dir ui exec vitest run --exclude 'e2e/**' --exclude 'src/components/chat/hooks/streamSmoother.test.ts' --exclude 'server/services/desktopUpdateNetwork.test.ts' + --exclude 'server/services/desktopUpdates.spec.ts' docker: runs-on: ubuntu-latest