From c31c1eeb44cc0780a3530e8aa81d4917ceffec5b Mon Sep 17 00:00:00 2001 From: mike-inkeep Date: Tue, 21 Jul 2026 22:38:40 -0400 Subject: [PATCH] =?UTF-8?q?Desktop=20Windows/Linux=20port=20=E2=80=94=20P1?= =?UTF-8?q?=20buildability=20+=20Tier=20B=20parity=20(#2707)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * spec: desktop windows/linux port — scope-frozen SPEC + evidence * feat(desktop): electron-builder win/linux targets, per-platform natives, cross-platform fuse hooks P1 buildability slice of specs/2026-07-16-desktop-windows-linux-port (D1, D2, D9, D10 packaging halves): - electron-builder.yml: NSIS win x64+arm64 (one-click, per-user, per-arch installers, version-less artifact names); linux AppImage+deb x64+arm64; per-platform extraResources for the ok wrapper + keyring binaries (darwin rules moved under mac:); node-pty excluded off-mac (D7 terminal dock dark) - build/installer.nsh: HKCU user-PATH append/remove for the bundled CLI + openknowledge:// protocol registry keys (electron-builder protocols is mac-Info.plist-only) - build/deb-postinst.sh / deb-postrm.sh: default template copies + /usr/bin/{ok,open-knowledge} symlinks - resources/cli/bin/ok.cmd + ok.ps1 (Windows wrappers) and ok-linux.sh (flat-layout Linux wrapper), same self-diagnosing exit-69 contract - scripts/afterPack.mjs + afterSign.mjs: fuses flip + verify on ALL platforms (D9); helper-bundle clone, node-pty chmod, notarize stay darwin-only; shared scripts/resolve-electron-binary.mjs - scripts/prepare-platform-natives.mjs: force-install win/linux keyring prebuilds for cross-arch builds; build:win / build:linux scripts - tests: per-platform extraResources merge in cli-native-deps test, wrapper-shipping assertions, ok-linux.sh contract tests * ci(desktop): manual artifact-only Windows/Linux build workflow (D6) workflow_dispatch only, publishes nothing, triggers nothing: ubuntu prepare job builds the workspace (CLI build chain is bash-shaped) and hands cli/dist + the staged native-config addon to per-OS packaging jobs (electron-vite + electron-builder). Azure Artifact Signing is optional via AZURE_* secrets/vars; unsigned Windows output is QA-only. * feat(desktop): win/linux window chrome (D8), pty capability gate (D7), deep-link self-registration (D2/Q8) - window-chrome.ts: titleBarStyle hidden + theme-matched titleBarOverlay + solid background for non-darwin windows; nativeTheme 'updated' re-applies (setTitleBarOverlay win32-only, background everywhere); autoHideMenuBar keeps the native menu row hidden while its accelerators stay live (D11 contract) - renderer: electron-platform-* class from the FOUC script; win/linux collapse the traffic-light left reserve and reserve the right edge for the Window Controls Overlay via titlebar-area env() vars - ptyAvailable on OkDesktopConfig (desktop+app mirrors, preload + get-info): terminal dock affordances, Settings Terminal section and the Terminal menu handlers all hide off-mac instead of surfacing a node-pty spawn failure; dock stays available for future non-pty content - url-scheme.ts: packaged win/linux setAsDefaultProtocolClient self-heal per boot; appimage-integration.ts writes the x-scheme-handler .desktop entry for AppImage launches (APPIMAGE env), XDG_DATA_HOME-aware, best-effort xdg refresh * feat: Tier B parity off-darwin — MCP/skill reclaim, PATH install, ok desktop auto-launch (D4/D10) - install-shape.ts: shared packaged-install classifier (mac bundle / NSIS / linux dir / appimage / unsupported) replacing the darwin-only .app regex across every machine-integration gate; AppImage declines all persistent-path integrations (its mount path dies each boot) - bundle-paths.ts: per-platform ok wrapper resolution (ok.cmd on win, flat resources/ layout on linux) — also fixes the packaged local-op CLI spawn path off-mac - path-install.ts: linux runs the full rc/symlink flow (adds .bashrc for bash logins, keeps mac behavior byte-identical); win32 returns skipped/installer-managed (NSIS owns user PATH per D10) - mcp-wiring / skill-reclaim / project-mcp-reclaim: gates lifted to the classifier; inner machinery was already platform-parameterized via cli editors.ts (win32 chain shape, %APPDATA%/XDG config resolvers) - integrations settings (user + project scope) available on win/linux via the same shared predicate - cli desktop-dispatch: per-platform install probes (%LOCALAPPDATA% NSIS dir, /opt deb path, wrapper introspection on both) + direct-exe launch off-mac; linux headless gate also requires a display server; reason 'unsupported-platform' added ('darwin-only' retired from emission, kept in the union) - resolve-detached-spawn-args: windowsHide on the detached server spawn * feat(desktop): custom Windows/Linux renderer menubar (D11) macOS keeps the native menu bar; win/linux draw a VS Code-style menubar in the chrome row (shadcn Menubar, Lingui'd labels) since D8's hidden titlebar leaves no native one. Contract per D11: - ok:menu:dispatch — ONE discriminated RequestChannel (sharing-dispatch precedent; ratchet 83→84): 'query' returns the aggregated snapshot (active target + view-menu state + recents + capability flags) that drives the native menu's enable/check rendering; 'menu-action' relays through the same sendMenuActionToFocused path native items use; 'role' maps onto Electron menu-role behaviors; 'command' reuses the native deps' click handlers (navigator, folder dialog, settings, updater, MCP re-arm — extracted to reconfigureMcpWiringFlow) - accelerators stay registered on the (hidden) main-process Menu, so shortcuts work without the DOM menubar focused; MenubarShortcut strings are display-only - AppMenubar mounts in EditorHeader (all window kinds incl. single-file) and the Navigator chrome row; Terminal menu deliberately absent off-mac (D7 pty gate) - menu namespace added to the three OkDesktopBridge copies + preload; types 4-way mirrored per the EditorViewMenuStateSnapshot lockstep * docs: changeset + AGENTS.md desktop-platform line for the win/linux port * chore: strip spec decision markers from source comments (comment discipline) * test(app): widen the FOUC electron-mode source guard for the platform-class stamp * test(app): model pty capability in terminal-surface DOM fixtures The dock and Settings gates now require config.ptyAvailable; the mocks model the capable macOS host, plus a new pty-less-host case. * fix: lazy-load AppMenubar (size gate) + normalize trailing-semicolon PATH in NSIS include - AppMenubar (and its radix Menubar primitive) now loads via lazy() behind the tiny app-menubar-gate predicate — web + macOS never download the chunk; reclaims 9.5 kB of the eager bundle. Main-app size limit 476->477 kB for the 12-byte residual (rationale in the size note). - installer.nsh strips a pre-existing trailing ';' from HKCU Path before appending so the write never produces an empty PATH segment (review nit). * test(desktop): pin platforms in host-dependent tests; cover packaged win/linux protocol self-heal CI's test tier runs on Linux — three tests keyed off host defaults my per-platform changes made host-sensitive: - bundle-paths.test.ts pins 'darwin' (the default arg is process.platform for the production call sites) - computePathInstallDescriptor / isPathShimInstalled / removePathShimFromRcFiles gain an explicit platform opt (defaults to the host); tests pin 'darwin' so the linux .bashrc target doesn't leak into mac-shape assertions - url-scheme-handler.test.ts pins the packaged-no-registration assertion to darwin and adds packaged win32/linux self-heal coverage (setAsDefaultProtocolClient per boot, no before-quit removal) * test(cli): pin darwin in launchDesktop spawn-shape tests; cover win/linux direct-exe launch Same Linux-CI-host class as the desktop test pins: launchDesktop defaults platform to process.platform, so the open -b assertions only hold on darwin. Adds per-platform direct-exe spawn-shape coverage (detached + stdio ignore + unref, args empty). * review: tarball integrity pin, menu-type drift catcher, off-mac done-path coverage, message/log fixes Addresses the claude[bot] review majors + minors: - prepare-platform-natives.mjs verifies each fetched keyring tarball's sha512 against pnpm-lock.yaml before extraction (fail-closed on a missing lockfile entry) and cleans the temp tarball on failure - bridge-contract-types.test.ts gains 4-way Eq assertions for MenuDispatchRequest/MenuRendererSnapshot (the ipc-channels copy is the one call-site typechecks miss) - linux/win32 done-path tests: project MCP repair cycle on NSIS + deb exec shapes; user skill reclaim through the deb shape (launchDesktop spawn shapes landed in the previous commit) - notFoundMessage stat-error/available cases no longer hardcode the macOS install path; open-settings executeJavaScript catches window teardown; AppImage registration logs route through the desktop logger; integrations-settings available JSDoc updated to the install-shape gate; prepare-universal.mjs error says pnpm install * fix(desktop): guard StrFunc declarations per NSIS compile pass electron-builder compiles installer.nsh twice (BUILD_UNINSTALLER pass, then installer pass); an unreferenced StrFunc declaration in either pass trips makensis warning 6010, which electron-builder promotes to an error. Caught on the first real makensis run (arm64 QA build) — the manual build workflow has not been dispatched yet, so CI never compiled it. * fix(desktop): three Windows-install defects found by live VM QA - NSIS payload: useZip + differentialPackage:false. The default solid-7z payload fails extraction under Windows-on-ARM's x86 emulation (Nsis7z drops the block holding the exe and every dll — half-install with no error), and useZip alone is ignored while the build is differential-aware, which stages 7z bytes under a .zip name and dies with 'Error opening ZIP file'. A true zip payload installs correctly (VM-verified end-to-end). - Exclude native-config's darwin cargo target/ tree from win/linux packages: 65 MB dead weight (28% of the installer) whose .fingerprint paths exceed MAX_PATH and survive uninstall. - CLI desktop dispatch: probe the real one-click install dir (sanitized package name, not productName) and treat isTTY=undefined as the interactive-console signature on win32 — Electron-as-Node stdio are pipes on Windows, so bare `ok` always read as headless and never launched the desktop. * fix(desktop,cli): Linux-install defects found by live VM QA - deb maintainer scripts: the comment WARNING about FpmTarget's macro templating itself contained a dollar-brace token, which the templater rejects — first-ever FpmTarget run caught it (deb was never built). - deb metadata: electron-builder requires homepage + author.email for the Debian control file; the private desktop package.json had neither, so the CI linux build would have died the same way. - MCP chain v2: the unix resolver chain never probed the Linux deb install — a deb-only user's entry fell through to npx @latest (version drift) or, with no Node, exit 127 'install OK Desktop' while OK Desktop was installed. v2 inserts the /opt/OpenKnowledge probe between the mac bundle probes and the npx fallback; the sentinel bump makes existing v1 entries classify stale, and the desktop repair sweep upgrades them in place (VM-verified: all user-global + project entries migrated on first boot, and the written entry execs the deb wrapper). - Wired-at-all markers in skill-reclaim + repair-skills now match the version-independent '# ok-mcp-' family prefix (the shape worktree-setup-inherit already used, and whose JSDoc names this exact trap) — pinning them to one version would have broken wired-detection for every existing v1 project after the bump. * fix(cli): share publish inherits the ambient env for git spawns simple-git's .env(obj) REPLACES the child environment, and makeGit passed a bare { GIT_TERMINAL_PROMPT: '0' } — stripping USERPROFILE/SystemRoot/ PATH on Windows (no global config, no HTTPS transport) AND the GIT_AUTHOR_* identity fallback the command sets for machines with no git identity. The initial commit died with 'Please tell me who you are' AFTER the GitHub repo was created, leaving a half-published project the wizard can't resume (name-check blocks on the now-taken name). POSIX hosts survived by accident: execvp's default PATH finds /usr/bin/git and the passwd-db HOME fallback finds config. VM-verified on a real Windows install: repo created, origin set, zero commits. Fix: share makeGit with clone.ts's plumbing — buildCloneEnv (spread process.env + prompt/locale overrides) and buildCloneGitOptions (the unsafe flags simple-git needs to accept an env carrying the user's PAGER/EDITOR/SSH vars). New regression test proves env-only identity reaches the commit child with every config-file source isolated. * fix(desktop): restore System32 on the Windows server PATH so Open-with-Claude/Cursor work On Windows, `process.env`'s search-path key is `Path`, not `PATH`. The detached- server spawn read `env.PATH` case-sensitively (undefined on Windows), so `buildEnrichedPath` collapsed the child's PATH to the git-enrichment dirs only — dropping `C:\Windows\System32`. The server's `reg` (handoff install-detection), `rundll32` (protocol-URL dispatch), and `cmd.exe`/`where` (Cursor spawn) are bare command names libuv resolves via the child's PATH, so they failed ENOENT. Result: `POST /api/handoff` returned 422 and "Open with Claude/Cursor" silently no-op'd on the desktop app — even though the renderer's Electron `getApplicationInfoForProtocol` detection correctly showed them installed (the web app was unaffected because its server inherits the shell's real `Path` directly). Read the inherited PATH case-insensitively and emit exactly one canonical `PATH` key (strip case-variants from the spread so libuv can't break a `Path`-vs-`PATH` tie by keeping the un-enriched or git-only value). Verified on the ARM64 Win11 VM: `POST /api/handoff` went 422 -> 200 and Claude + Cursor launched with the correct workspace/prompt. Added a `Path`-cased regression test — the existing coverage only exercised uppercase `PATH`. * fix(desktop): address PR review — catch open-settings executeJavaScript, pnpm comment - openSettings menu handler: `.catch()` the `executeJavaScript` promise (TOCTOU window teardown) so a mid-dispatch rejection degrades to a no-op instead of an unhandledRejection, matching the sibling `open-settings` IPC handler. - electron-builder.yml: stale "Bun" → "pnpm" in the keyring cross-arch comment (repo migrated to pnpm; sibling comment already says pnpm). * fix(desktop): gate the composer Terminal handoff group on ptyAvailable (win/linux) The "Ask AI" bottom composer offered a Terminal group (Claude/Codex CLI) on win/linux desktop even though node-pty is excluded there (D7), so picking it was a silent no-op — the docked terminal in EditorPane never mounts, so the launch routed into nothing (no terminal, no process, no error). App.tsx built the TerminalLaunchProvider value (read via useTerminalLaunch by BottomComposer, DocPanel, CreatePromptComposer, OpenInAgentContextSubmenu) gated only on desktopBridge presence, not config.ptyAvailable. EditorPane, the Settings -> Terminal section, and the AppMenubar Terminal items already gate on it; this provider was the one that leaked. Now win/linux desktop offers only the working Desktop (URL-scheme) handoffs; macOS is unchanged. Matches the port changeset's own claim that the built-in terminal is hidden off-mac instead of failing, so no new changeset. App.dom.test.tsx's createBridge() mock gains the `config` the real preload always exposes (App now reads config.ptyAvailable). Verified on the Linux arm64 VM (deb v0.34.0): the Terminal group disappears from the composer, only the Desktop group remains, and the Cursor handoff still launches Cursor end-to-end. * fix(desktop): drop require() in ok-wrapper test (main's new oxlint rule) The merge brought main's #2762 oxlint rule forbidding require() in ESM test modules. This branch's ok-wrapper NODE_OPTIONS test used `require('node:fs')`, which the auto-merge left redundant beside the static `readFileSync` import main added to the same file. Use the static import. * fix(cli): point acp-harness-probe fixture at CHAIN_V2 after merge This branch's Linux-install VM-QA fix (22667b1c73) bumped the canonical managed-MCP chain CHAIN_V1 (`# ok-mcp-v1`) → CHAIN_V2 (`# ok-mcp-v2`) and removed the V1 export. Main independently added the opencode harness-probe test importing CHAIN_V1; the auto-merge kept the V2 source + the V1-importing test, so `openCodePublished()`'s chain body was `undefined` and both opencode matcher assertions failed. Retarget the fixture at CHAIN_V2 (the chain OK now publishes). No other CHAIN_V1 references remain. * fix(test): migrate PR-touched test files off the bun:test shim (main's #2757 guard) Merging main brought its incremental guard forbidding `bun:test` imports in files a change touches (the shim is grandfathered only for the untouched corpus). This branch's 23 win/linux-port + MCP test files still imported the shim, so `open-knowledge / lint` failed on them. They passed `pnpm check` locally because the shim aliases bun:test→vitest at runtime; the guard is a CI-only diff-scoped step. - 21 files: pure `from 'bun:test'` → `from 'vitest'` (describe/test/it/expect/ hooks map 1:1). - url-scheme-handler.test.ts: `mock` was only function spies → `vi.fn` (`.mock.calls` is vitest-native). - App.dom.test.tsx: `mock.module` → `vi.doMock` (30+); the SUT was already a post-mock dynamic `import('./App')`, so the non-hoisted doMock applies. The shim itself implements mock.module via vi.doMock, so behavior is identical. biome organizeImports re-sorted the moved specifiers. All 23 files' tests pass (cli 428, desktop 134+78, app 11+6); guard + full lint green. * fix(desktop): restore win/linux protocol self-heal test after merge The second origin/main merge resolved url-scheme-handler.test.ts as a bun:test double-migration and took main's version wholesale — but that version also carried main's OLD assertion ("does NOT call setAsDefaultProtocolClient in packaged builds"), silently reverting this branch's win/linux split. On the Linux CI host packaged builds DO self-heal the scheme binding (url-scheme.ts:900 `else if (platform !== 'darwin')`), so the un-split assertion failed (called once with 'openknowledge'). Re-applied the split onto main's version (keeping main's new foreign-host trust-gate tests): darwin packaged → not called (platform-pinned); win32/linux packaged → self-heal per boot, no before-quit. Desktop suite 2703 pass; guard clean. * test(server): de-flake git-preflight-boot temp-dir teardown (ENOTEMPTY) The `gitEnabled:false` boot test intermittently failed the macOS preflight CI job with `ENOTEMPTY: directory not empty, rmdir '.../state-*'`. `booted.destroy()` is awaited before the afterEach, but the server's async sinks (persistence debounce, telemetry export, pino flush) can land a final write into the state dir a beat after destroy resolves; on a loaded macOS runner that write races the recursive removal, so the plain `rm` bails with ENOTEMPTY. Retry the removal with linear backoff (maxRetries: 10, retryDelay: 100 → ~5.5s) so it outlasts any trailing write. Behavior-preserving — same cleanup, resilient to the race. Follow-up to #2775 (which addressed the sibling destroyHocuspocus teardown-timeout symptom). Migrated the file's import off the bun:test shim to vitest in lock-step (editing it brings it under the diff-scoped #2757 guard). Passes 3/3 locally; the whole file is bun:test-free. --------- GitOrigin-RevId: 2d446f5ec3daa98a14613d9f813cd236b1885611 --- .changeset/desktop-windows-linux-port.md | 10 + .github/workflows/desktop-build-win-linux.yml | 285 +++++++++++++ packages/app/index.html | 2 +- packages/app/src/App.dom.test.tsx | 100 ++--- packages/app/src/App.tsx | 26 +- .../app/src/build/electron-mode-class.test.ts | 15 +- packages/app/src/components/AppMenubar.tsx | 378 ++++++++++++++++++ packages/app/src/components/EditorHeader.tsx | 22 +- .../src/components/EditorPane.dom.test.tsx | 5 + packages/app/src/components/EditorPane.tsx | 24 +- packages/app/src/components/NavigatorApp.tsx | 21 +- .../app/src/components/app-menubar-gate.ts | 13 + .../SettingsDialogShell.terminal.dom.test.tsx | 16 +- .../settings/SettingsDialogShell.tsx | 7 +- packages/app/src/components/ui/menubar.tsx | 257 ++++++++++++ packages/app/src/globals.css | 21 + packages/app/src/lib/desktop-bridge-types.ts | 80 ++++ packages/app/src/locales/en/messages.json | 30 ++ packages/app/src/locales/en/messages.po | 147 +++++++ packages/app/src/locales/pseudo/messages.json | 30 ++ packages/app/src/locales/pseudo/messages.po | 147 +++++++ .../tests/stress/fixtures/handoff-mocks.ts | 5 + packages/cli/src/cli.ts | 2 +- .../src/commands/acp-harness-probe.test.ts | 4 +- .../cli/src/commands/desktop-dispatch.test.ts | 175 +++++++- packages/cli/src/commands/desktop-dispatch.ts | 171 ++++++-- packages/cli/src/commands/editors.test.ts | 80 ++-- packages/cli/src/commands/editors.ts | 53 ++- packages/cli/src/commands/init.test.ts | 10 +- .../src/commands/mcp-chain-validation.test.ts | 20 +- .../cli/src/commands/mcp-config-removal.ts | 2 +- .../mcp-cross-harness-acceptance.test.ts | 8 +- .../commands/mcp-json-surgical-write.test.ts | 8 +- .../cli/src/commands/mcp-migrate-event.ts | 4 +- .../commands/mcp-toml-surgical-write.test.ts | 10 +- .../commands/mcp-yaml-surgical-write.test.ts | 6 +- packages/cli/src/commands/repair-skills.ts | 31 +- .../cli/src/commands/share/publish.test.ts | 90 ++++- packages/cli/src/commands/share/publish.ts | 40 +- packages/cli/src/commands/start.ts | 2 +- .../cli/src/integrations/pi-extension.test.ts | 8 +- .../project-integration-writers.test.ts | 4 +- packages/core/src/desktop-bridge.ts | 70 ++++ packages/desktop/build/deb-postinst.sh | 72 ++++ packages/desktop/build/deb-postrm.sh | 34 ++ packages/desktop/build/installer.nsh | 100 +++++ packages/desktop/electron-builder.yml | 189 ++++++++- packages/desktop/package.json | 8 + .../desktop/resources/cli/bin/ok-linux.sh | 52 +++ packages/desktop/resources/cli/bin/ok.cmd | 38 ++ packages/desktop/resources/cli/bin/ok.ps1 | 26 ++ packages/desktop/scripts/afterPack.mjs | 36 +- packages/desktop/scripts/afterSign.mjs | 40 +- .../scripts/prepare-platform-natives.mjs | 143 +++++++ .../desktop/scripts/prepare-universal.mjs | 2 +- .../scripts/resolve-electron-binary.mjs | 36 ++ .../src/main/appimage-integration.test.ts | 117 ++++++ .../desktop/src/main/appimage-integration.ts | 133 ++++++ .../desktop/src/main/bundle-paths.test.ts | 7 +- packages/desktop/src/main/bundle-paths.ts | 25 +- packages/desktop/src/main/index.ts | 285 ++++++++++++- .../desktop/src/main/install-shape.test.ts | 85 ++++ packages/desktop/src/main/install-shape.ts | 69 ++++ .../desktop/src/main/integrations-settings.ts | 7 +- packages/desktop/src/main/mcp-wiring.ts | 38 +- .../desktop/src/main/path-install.test.ts | 101 ++++- packages/desktop/src/main/path-install.ts | 62 ++- .../src/main/project-mcp-reclaim.test.ts | 34 +- .../desktop/src/main/project-mcp-reclaim.ts | 13 +- .../main/resolve-detached-spawn-args.test.ts | 31 ++ .../src/main/resolve-detached-spawn-args.ts | 42 +- .../desktop/src/main/skill-reclaim.test.ts | 33 +- packages/desktop/src/main/skill-reclaim.ts | 53 ++- packages/desktop/src/main/url-scheme.ts | 22 + .../desktop/src/main/window-chrome.test.ts | 105 +++++ packages/desktop/src/main/window-chrome.ts | 97 +++++ .../src/main/worktree-setup-inherit.ts | 2 +- packages/desktop/src/preload/index.ts | 15 + .../desktop/src/shared/bridge-contract.ts | 80 ++++ packages/desktop/src/shared/ipc-channels.ts | 91 ++++- .../ipc-channel-count-ratchet.test.ts | 14 +- .../tests/main/url-scheme-handler.test.ts | 27 +- .../desktop/tests/smoke/mcp-wiring.e2e.ts | 2 +- .../tests/unit/bridge-contract-types.test.ts | 49 ++- .../electron-builder-cli-native-deps.test.ts | 98 ++++- .../desktop/tests/unit/ok-wrapper.test.ts | 31 ++ .../server/src/git-preflight-boot.test.ts | 11 +- 87 files changed, 4554 insertions(+), 440 deletions(-) create mode 100644 .changeset/desktop-windows-linux-port.md create mode 100644 .github/workflows/desktop-build-win-linux.yml create mode 100644 packages/app/src/components/AppMenubar.tsx create mode 100644 packages/app/src/components/app-menubar-gate.ts create mode 100644 packages/app/src/components/ui/menubar.tsx create mode 100644 packages/desktop/build/deb-postinst.sh create mode 100644 packages/desktop/build/deb-postrm.sh create mode 100644 packages/desktop/build/installer.nsh create mode 100755 packages/desktop/resources/cli/bin/ok-linux.sh create mode 100644 packages/desktop/resources/cli/bin/ok.cmd create mode 100644 packages/desktop/resources/cli/bin/ok.ps1 create mode 100644 packages/desktop/scripts/prepare-platform-natives.mjs create mode 100644 packages/desktop/scripts/resolve-electron-binary.mjs create mode 100644 packages/desktop/src/main/appimage-integration.test.ts create mode 100644 packages/desktop/src/main/appimage-integration.ts create mode 100644 packages/desktop/src/main/install-shape.test.ts create mode 100644 packages/desktop/src/main/install-shape.ts create mode 100644 packages/desktop/src/main/window-chrome.test.ts create mode 100644 packages/desktop/src/main/window-chrome.ts diff --git a/.changeset/desktop-windows-linux-port.md b/.changeset/desktop-windows-linux-port.md new file mode 100644 index 000000000..3a9f87238 --- /dev/null +++ b/.changeset/desktop-windows-linux-port.md @@ -0,0 +1,10 @@ +--- +"@inkeep/open-knowledge": minor +--- + +The OpenKnowledge desktop app can now be built for Windows and Linux. This first slice makes the app buildable and full-featured on both platforms — installers are not published yet (they'll appear on the releases page after internal QA): + +- Windows: one-click per-user NSIS installers (x64 + arm64) that put the bundled `ok` CLI on your PATH and register `openknowledge://` links. Linux: AppImage and deb packages (x64 + arm64); the deb installs `/usr/bin/ok` and registers links system-wide, while AppImages self-register a link handler each time they run. +- Windows and Linux windows get proper chrome: a frameless titlebar with native window controls and an in-app menu bar (File / Edit / View / Window / Help) that mirrors the macOS menus. +- Everything the Mac app wires up on your machine now works on Windows and Linux too: MCP entries for your editors (Claude, Cursor, Codex, and friends), Agent Skills, and `ok` launching the desktop app when installed. +- The built-in terminal stays macOS-only for now; its buttons and settings are hidden on other platforms instead of failing. diff --git a/.github/workflows/desktop-build-win-linux.yml b/.github/workflows/desktop-build-win-linux.yml new file mode 100644 index 000000000..864f8dd8b --- /dev/null +++ b/.github/workflows/desktop-build-win-linux.yml @@ -0,0 +1,285 @@ +name: Desktop build (manual, Windows/Linux) + +# Manual, artifact-only Windows + Linux desktop builds (spec +# 2026-07-16-desktop-windows-linux-port, D6): nothing here publishes, +# tags, or touches the release cadence — the operator downloads the +# installers from the run's artifacts for VM/hardware QA. Cadence wiring +# (desktop-release.yml jobs, updates-proxy widening, download surfaces) +# is deferred to P4 after QA sign-off. +# +# Topology: one `prepare` job on ubuntu builds the workspace (the CLI +# build chain uses `cp -r`/`mkdir -p` bash-isms that don't survive a +# Windows runner's default shell, and this also keeps the two packaging +# jobs from redundantly rebuilding identical platform-neutral JS), then +# per-OS jobs do only the platform-specific half: electron-vite build + +# electron-builder against the prebuilt cli/dist. +# +# Windows signing (D3, Azure Artifact Signing): OPTIONAL here. When the +# three AZURE_* secrets + three AZURE_SIGNING_* repo variables are +# configured, the Windows job passes win.azureSignOptions to +# electron-builder; when absent it builds unsigned (acceptable for +# internal QA only — unsigned Windows builds are never published, and +# this workflow cannot publish by construction). +# +# afterPack/afterSign flip + verify the Electron fuses on every platform +# (D9) — RunAsNode must be enabled or the bundled `ok` CLI wrappers and +# the detached-server spawn silently break in packaged builds. + +on: + workflow_dispatch: + inputs: + platforms: + description: "Which platforms to build" + required: true + type: choice + default: both + options: [both, windows, linux] + +permissions: + contents: read + # `gh run download` of the native-config prebuild artifact. + actions: read + +concurrency: + group: desktop-build-win-linux-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-ok-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-ok-pnpm-store- + + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-desktop-manual-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-turbo-desktop-manual- + ${{ runner.os }}-turbo-desktop- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Best-effort staging of the 8-target native-config prebuilt set + # (mirrors release.yml's step in spirit, but always degrades instead + # of failing: these are QA artifacts, not published releases, and the + # smol-toml fallback keeps the CLI functional without the addon — + # only the Codex TOML write degrades to a decline). The host `napi + # build` below still produces the linux-x64 binary either way. + - name: Stage native-config prebuilt binaries (best-effort) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + run_id=$(gh run list --workflow=native-config-prebuild.yml --branch main --event push \ + --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true) + if [ -z "$run_id" ]; then + echo "::warning::no successful native-config-prebuild run found; win/arm64 targets ship without the toml_edit addon (smol-toml fallback)" + exit 0 + fi + if ! gh run download "$run_id" --name native-config-bindings-all --dir /tmp/nc-bindings; then + echo "::warning::could not download native-config-bindings-all from run $run_id; continuing with host binary only" + exit 0 + fi + find /tmp/nc-bindings -name '*.node' -exec cp -f {} packages/native-config/ \; + count=$(find packages/native-config -maxdepth 1 -name '*.node' | wc -l | tr -d ' ') + echo "staged $count native-config prebuilt binaries from run $run_id" + + # The workspace build produces native-config's host `.node` via `napi + # build` (turbo dep of the cli build), which needs a Rust toolchain — + # mirror release.yml's explicit pin. + - name: Setup Rust toolchain for the native-config build + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + + # Same two-filter sequencing as desktop-release.yml: app must build + # before cli's build:assets copies app/dist into cli/dist/public. + - name: Build workspace (produces packages/cli/dist for extraResources) + run: | + pnpm exec turbo run build --filter=@inkeep/open-knowledge-app + pnpm exec turbo run build --filter=@inkeep/open-knowledge + + # Hand the platform-neutral build products to the packaging jobs: + # the full bundled CLI tree + the native-config addon dir the + # electron-builder `../native-config` extraResources rule reads + # (index.js loader + package.json + every staged .node). + - name: Upload workspace bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: workspace-bundle + path: | + packages/cli/dist/ + packages/native-config/index.js + packages/native-config/index.d.ts + packages/native-config/*.node + retention-days: 3 + if-no-files-found: error + + build-windows: + if: inputs.platforms == 'both' || inputs.platforms == 'windows' + needs: prepare + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-ok-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-ok-pnpm-store- + + - name: Install dependencies + env: + # Rebuild native modules against Electron's Node ABI — mirrors + # desktop-release.yml. + ELECTRON_SKIP_REBUILD: "0" + run: pnpm install --frozen-lockfile + + - name: Download workspace bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v5.0.0 + with: + name: workspace-bundle + path: packages/ + + # Both keyring arch prebuilds (x64 + arm64) must be present in the + # hoisted node_modules before electron-builder copies them into + # cli/node_modules (win.extraResources) — pnpm installed only the + # host-matching one. + - name: Prepare cross-arch keyring prebuilds + working-directory: packages/desktop + run: node scripts/prepare-platform-natives.mjs + + - name: Build desktop main/preload/renderer + working-directory: packages/desktop + run: pnpm run build:desktop + + # Signing is optional (see header). Secrets: AZURE_TENANT_ID / + # AZURE_CLIENT_ID / AZURE_CLIENT_SECRET; repo variables: + # AZURE_SIGNING_ENDPOINT / AZURE_SIGNING_ACCOUNT / AZURE_SIGNING_PROFILE + # (Azure Artifact Signing endpoint URL, code-signing account name, + # certificate profile name — procurement outputs). + - name: Package NSIS installers (x64 + arm64) + working-directory: packages/desktop + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + run: | + set -euo pipefail + extra=() + if [[ -n "${AZURE_CLIENT_SECRET:-}" && -n "${AZURE_SIGNING_ENDPOINT:-}" ]]; then + echo "::notice::Azure Artifact Signing credentials present — building SIGNED installers." + extra+=( + "--config.win.azureSignOptions.endpoint=${AZURE_SIGNING_ENDPOINT}" + "--config.win.azureSignOptions.codeSigningAccountName=${AZURE_SIGNING_ACCOUNT}" + "--config.win.azureSignOptions.certificateProfileName=${AZURE_SIGNING_PROFILE}" + ) + else + echo "::warning::No Azure signing credentials — building UNSIGNED installers (internal QA only; never distribute)." + fi + pnpm exec electron-builder --win --publish never "${extra[@]}" + + - name: Upload Windows artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: open-knowledge-windows-${{ github.run_number }} + path: | + packages/desktop/dist-desktop/*.exe + packages/desktop/dist-desktop/*.exe.blockmap + packages/desktop/dist-desktop/latest.yml + retention-days: 14 + if-no-files-found: error + + build-linux: + if: inputs.platforms == 'both' || inputs.platforms == 'linux' + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-ok-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-ok-pnpm-store- + + - name: Install dependencies + env: + # Rebuild native modules against Electron's Node ABI — mirrors + # desktop-release.yml. + ELECTRON_SKIP_REBUILD: "0" + run: pnpm install --frozen-lockfile + + - name: Download workspace bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v5.0.0 + with: + name: workspace-bundle + path: packages/ + + - name: Prepare cross-arch keyring prebuilds + working-directory: packages/desktop + run: node scripts/prepare-platform-natives.mjs + + - name: Build desktop main/preload/renderer + working-directory: packages/desktop + run: pnpm run build:desktop + + - name: Package AppImage + deb (x64 + arm64) + working-directory: packages/desktop + run: pnpm exec electron-builder --linux --publish never + + - name: Upload Linux artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: open-knowledge-linux-${{ github.run_number }} + path: | + packages/desktop/dist-desktop/*.AppImage + packages/desktop/dist-desktop/*.deb + packages/desktop/dist-desktop/latest-linux.yml + retention-days: 14 + if-no-files-found: error diff --git a/packages/app/index.html b/packages/app/index.html index d2d3b658d..511ce2994 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -56,7 +56,7 @@ Single-line script body — same biome HTML-formatter constraint as the editor-mode FOUC above. --> - + diff --git a/packages/app/src/App.dom.test.tsx b/packages/app/src/App.dom.test.tsx index cc489d39d..ee64c2e84 100644 --- a/packages/app/src/App.dom.test.tsx +++ b/packages/app/src/App.dom.test.tsx @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { expectVisualClassTokens } from '@/test-utils/visual-contract'; type NavigationTarget = @@ -22,7 +22,7 @@ let openTabs: string[] = []; let loading = false; let singleFileMode = false; let tabSessionLoaded = true; -let fetchApiConfigMock = mock(() => +let fetchApiConfigMock = vi.fn(() => Promise.resolve({ status: 'ok' as const, config: { @@ -33,21 +33,21 @@ let fetchApiConfigMock = mock(() => }, }), ); -let clearTargetMock = mock(() => {}); -let syncOpenTabsWithKnownTargetsMock = mock(() => {}); -let openTargetTransitionMock = mock((_: NavigationTarget) => {}); -let resolveNavigationTargetMock = mock( +let clearTargetMock = vi.fn(() => {}); +let syncOpenTabsWithKnownTargetsMock = vi.fn(() => {}); +let openTargetTransitionMock = vi.fn((_: NavigationTarget) => {}); +let resolveNavigationTargetMock = vi.fn( (docName: string): NavigationTarget => ({ kind: 'doc', target: docName, docName }), ); -let downgradeFolderIndexForHashNavMock = mock((target: NavigationTarget) => target); -let withLargeFileOpenGuardMock = mock((target: NavigationTarget) => target); +let downgradeFolderIndexForHashNavMock = vi.fn((target: NavigationTarget) => target); +let withLargeFileOpenGuardMock = vi.fn((target: NavigationTarget) => target); -mock.module('@/lib/perf', () => ({ +vi.doMock('@/lib/perf', () => ({ mark: () => {}, ProfilerBoundary: ({ children }: { children: ReactNode }) => children, })); -mock.module('@/editor/DocumentContext', () => ({ +vi.doMock('@/editor/DocumentContext', () => ({ DocumentProvider: ({ children }: { children: ReactNode }) => (
{children}
), @@ -67,7 +67,7 @@ mock.module('@/editor/DocumentContext', () => ({ }), })); -mock.module('@/components/PageListContext', () => ({ +vi.doMock('@/components/PageListContext', () => ({ PageListProvider: ({ children }: { children: ReactNode }) => (
{children}
), @@ -83,7 +83,7 @@ mock.module('@/components/PageListContext', () => ({ }), })); -mock.module('@/components/navigation-targets', () => ({ +vi.doMock('@/components/navigation-targets', () => ({ resolveNavigationTarget: (...args: Parameters) => resolveNavigationTargetMock(...args), downgradeFolderIndexForHashNav: (target: NavigationTarget) => @@ -91,7 +91,7 @@ mock.module('@/components/navigation-targets', () => ({ withLargeFileOpenGuard: (target: NavigationTarget) => withLargeFileOpenGuardMock(target), })); -mock.module('@/lib/config-provider', () => ({ +vi.doMock('@/lib/config-provider', () => ({ ConfigProvider: ({ children }: { children: ReactNode }) => (
{children}
), @@ -100,82 +100,82 @@ mock.module('@/lib/config-provider', () => ({ // AppBody reads `merged.appearance.preview.autoOpen` to compose the // "Open in terminal" launch prompt; the ConfigProvider above is a passthrough // so the real context is never set. Stub the hook to the cold-start shape. -mock.module('@/lib/config-context', () => ({ +vi.doMock('@/lib/config-context', () => ({ useConfigContext: () => ({ merged: null }), })); -mock.module('@/lib/api-config', () => ({ +vi.doMock('@/lib/api-config', () => ({ fetchApiConfig: (...args: Parameters) => fetchApiConfigMock(...args), })); // ConfigProviderHost mounts the app-lifetime server keepalive; stub it so this // chrome-focused test doesn't open a real WebSocket. Behavior is covered by // use-server-keepalive.dom.test.tsx. -mock.module('@/lib/use-server-keepalive', () => ({ +vi.doMock('@/lib/use-server-keepalive', () => ({ useServerKeepalive: () => {}, })); -mock.module('@/lib/single-file-mode', () => ({ +vi.doMock('@/lib/single-file-mode', () => ({ SingleFileModeProvider: ({ children }: { children: ReactNode }) => (
{children}
), useSingleFileMode: () => singleFileMode, })); -mock.module('@/components/ConnectingBanner', () => ({ +vi.doMock('@/components/ConnectingBanner', () => ({ ConnectingBanner: () =>
, })); -mock.module('@/components/SystemDocSubscriber', () => ({ +vi.doMock('@/components/SystemDocSubscriber', () => ({ SystemDocSubscriber: () =>
, })); -mock.module('@/components/McpConsentDialog', () => ({ +vi.doMock('@/components/McpConsentDialog', () => ({ McpConsentDialog: () =>
, })); -mock.module('@/components/CommandPalette', () => ({ +vi.doMock('@/components/CommandPalette', () => ({ CommandPalette: ({ open }: { open: boolean }) => (
), })); -mock.module('@/components/AuthModal', () => ({ +vi.doMock('@/components/AuthModal', () => ({ AuthModal: ({ open }: { open: boolean }) => (
), })); -mock.module('@/components/InstallInClaudeDesktopDialog', () => ({ +vi.doMock('@/components/InstallInClaudeDesktopDialog', () => ({ InstallInClaudeDesktopDialog: ({ open }: { open: boolean }) => (
), })); -mock.module('@/components/CreateProjectMenuTrigger', () => ({ +vi.doMock('@/components/CreateProjectMenuTrigger', () => ({ CreateProjectMenuTrigger: () =>
, })); -mock.module('@/components/ReportBugMenuTrigger', () => ({ +vi.doMock('@/components/ReportBugMenuTrigger', () => ({ ReportBugMenuTrigger: () =>
, })); -mock.module('@/components/ShareBranchSwitchDialog', () => ({ +vi.doMock('@/components/ShareBranchSwitchDialog', () => ({ ShareBranchSwitchDialog: () =>
, })); -mock.module('@/components/ShareReceiveMissDialog', () => ({ +vi.doMock('@/components/ShareReceiveMissDialog', () => ({ ShareReceiveMissDialog: () =>
, })); -mock.module('@/components/NewItemDialog', () => ({ +vi.doMock('@/components/NewItemDialog', () => ({ isNewItemShortcut: () => false, NewItemDialog: ({ open, initialDir }: { open: boolean; initialDir: string }) => (
), })); -mock.module('@/components/FileSidebar', () => ({ +vi.doMock('@/components/FileSidebar', () => ({ FileSidebar: ({ onOpenSearch }: { onOpenSearch: () => void }) => (