OR-326 Add the Linux desktop app as a self-updating AppImage - #445
Conversation
The OpenResearch.app now hosts the dashboard in a native window (tao + wry WKWebView) instead of handing it to the user's browser. It adds a standard menu bar, save panels for downloads, a native window.confirm panel, and a Cmd+Q that flushes workspace state and shuts the server down cleanly. Pop-ups open in the system browser (http/https/mailto only). The app now prefers port 4792 so the window's localStorage survives relaunches. The Dock-click tab-focus script, its Apple-events entitlement, and the SSE client counter it relied on are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the Windows desktop app. A small GUI-subsystem OpenResearch.exe (windows/launcher) starts the orx.exe beside it as `orx app` in a hidden console, so orx and the git, shell, and agent processes it runs share one invisible console instead of each flashing a window. `orx app` shares the macOS window code in src/commands/app.rs: WebView2 window, pop-ups to the system browser, save panel, page-load reveal, and port 4792. On Windows, closing the window quits, a second launch focuses the running window (named mutex + event), and the taskbar groups the window with the Start menu shortcut. An update restart relaunches as the app on the same port. Quit on both platforms now goes through up::request_shutdown instead of a self-sent SIGTERM. An Inno Setup script builds a per-user OpenResearch-Setup.exe with a Start menu entry and a WebView2 bootstrap. CI builds it as an artifact, and release-windows-app.yml attaches it to releases once WINDOWS_APP_ENABLED is set. The icon is embedded via embed-resource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The focus listener captured its bare HANDLE (edition 2021 disjoint capture) instead of the Send wrapper, which failed to compile on Windows. - An orx.exe away from the CLI installer's prefix, like the app's, is now Portable even when the installer's receipt exists, so it can update itself. - Single instance creates its event before the mutex; the launcher hands its foreground rights to orx.exe. - Focus requests and server readiness are ignored while quitting, and focus waits for the first load. The save dialog is owned by the window. - Telemetry counts an app start only after the instance claim. - The installer reports a failed WebView2 bootstrap and shows progress. - Icon embedding now fails the build if no resource compiler is found. - CI format-checks the launcher; the release job drops an unpinned action. - Docs: maintainer notes for the release gate, two known gaps, and wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Keep the macOS save panel free-floating: only Windows gets the window as its owner, since a parent makes rfd show a sheet on macOS. - Treat the WebView2 bootstrap as failed unless the runtime is then present. - Move the UTF-16 helper out of the single-instance module, and bind the kernel object names before the mutex call that GetLastError follows. - Docs and a dead_code reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `desktop` cargo feature builds the windowed app on Linux (glibc, WebKitGTK), leaving the static musl CLI builds untouched. build.rs sets cfg(desktop_app) for macOS, Windows, and Linux with the feature, replacing the macOS-or-Windows gates. The AppImage's AppRun starts `orx app`, sharing the Windows entry point: the webview is built into the GTK window's box (X11 and Wayland), closing quits, a Unix socket in XDG_RUNTIME_DIR keeps one instance and focuses it, and the login shell's PATH is adopted as on macOS. scripts/build-linux-appimage.sh bundles WebKitGTK with pinned linuxdeploy, its GTK plugin, and appimagetool, copying WebKit's helper processes and rewriting libwebkit2gtk's /usr paths to ././ so they resolve inside the image. CI builds x86_64 and aarch64 AppImages on Ubuntu 22.04 and smoke-tests each under Xvfb; release-linux-app.yml attaches them with linux-app.json once LINUX_APP_ENABLED is set. The AppImage updates itself (InstallChannel::AppImage, updates/linux_app.rs): a sha256-checked download renamed over the file, production builds only, and Restart execs the new AppImage on the same port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review of the Linux desktop app: - Downloads froze the app: rfd's GTK backend runs its dialog on a thread of its own, which waits forever on the main context tao holds. The save dialog is now a gtk::FileChooserDialog run on the main thread. - The bundled WebKit helpers found their libraries only beside themselves, so they loaded the host's WebKit or none. They now get an RPATH to the image's usr/lib, and linuxdeploy no longer makes stray copies of them. - The GTK hook's variables reached every host program orx opens. AppRun now saves the session's values and orx hands them back to xdg-open, the folder picker, error dialogs, and agents. - The smoke test now runs after WebKitGTK is removed from the runner and checks that each WebKit helper runs, and loads libwebkit2gtk, from the image; cleanup kills the app's whole process group. - Startup failures now reach stderr and a zenity or kdialog dialog. - The tools and the AppImage runtime are pinned by sha256, and only the release job that publishes can write to the release. - Smaller fixes: the shell probe runs after the single-instance claim and falls back to /bin/sh; APPDIR is canonicalized and must not be empty or root; relative project paths resolve against home inside the image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restore the host GTK variables before an update relaunches the AppImage, so the new AppRun doesn't save the old image's values as the session's, and in the dashboard's terminals. Share one APPDIR containment check between the update channel, relative project paths, and the folder picker, which now keeps a terminal `orx up`'s working directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pick the relaunch target with the same APPDIR check as the update channel, so an `orx app` started from the app's terminal restarts itself. Hand the shell probe the session's GTK settings, harden the smoke test's helper checks and cleanup, and bring the docs up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its mount paths aren't absolute on Windows, which has no AppImage anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a UTM VM the Linux app ran with no window: it stays hidden until the page finishes loading, and that never happened. Show it 15 seconds after the server is up regardless, say so on stderr, and have the smoke test fail if that fallback fires or no window appears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st modules GNOME shows an unmatched window as a gear named after its class, so name the program OpenResearch and have each launch install a desktop entry and icon pointing at the AppImage (TryExec hides it once the file is gone). Bundle GLib's TLS module and set GIO_MODULE_DIR to the image's, so the bundled GLib stops loading the host's modules, built against a newer one. A second launch now shows the window even before the page loads, so a stuck instance can't swallow it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write the desktop entry only from the AppImage's own orx (shared with the update relaunch), keep a session's GIO_EXTRA_MODULES off the bundled GLib, mark a Dock-reopened macOS window as shown so the load fallback can't reopen it, close a race in the smoke test, and note the Fedora and openSUSE TLS gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # Cargo.lock # ui/dist/assets/index-DLDRL7rV.js # ui/dist/assets/index-UAhw--Xc.js # ui/dist/assets/index-yT9oNk64.js # ui/dist/index.html
…myles/desktop-app-linux # Conflicts: # ui/dist/assets/index-DLDRL7rV.js # ui/dist/assets/index-Df-zvt4S.js # ui/dist/assets/index-ZrkI6iDM.js # ui/dist/index.html
# Conflicts: # Cargo.lock # Cargo.toml # src/commands/app.rs # src/updates.rs
If the dashboard never finishes loading, show the window 15 seconds after the server is up and say so on stderr; a relaunch (or a Dock click on macOS) also shows it before the page loads. The installer and uninstaller now check the app's single-instance mutex and ask the user to close it, rather than replacing files under a running app and skipping its shutdown path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… launch without WebView2 Move the file a download replaces aside and restore it if the download fails or is cancelled (WebView2's flyout can cancel), rather than deleting it up front. The installer no longer offers to start OpenResearch when the WebView2 Runtime is still missing, since the window could not open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…myles/desktop-app-linux # Conflicts: # src/commands/app.rs
# Conflicts: # Cargo.toml # build.rs # src/commands/app.rs # src/commands/up.rs # src/main.rs # src/telemetry.rs # src/updates.rs # windows/launcher/src/main.rs
|
| let _ = std::fs::remove_file(&path); | ||
| match UnixListener::bind(&path) { |
There was a problem hiding this comment.
Concurrent launches lose socket ownership
If two AppImages launch together after a cold start or update restart, both can fail the initial socket connection. One can bind the socket, then the other can remove its pathname and bind a new socket. Both apps keep running, and later launches can no longer focus the first window. The single-instance claim needs to prevent another launch from removing a newly bound socket.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/app.rs
Line: 380-381
Comment:
**Concurrent launches lose socket ownership**
If two AppImages launch together after a cold start or update restart, both can fail the initial socket connection. One can bind the socket, then the other can remove its pathname and bind a new socket. Both apps keep running, and later launches can no longer focus the first window. The single-instance claim needs to prevent another launch from removing a newly bound socket.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| let dir = std::env::var_os("XDG_RUNTIME_DIR") | ||
| .map(PathBuf::from) | ||
| .unwrap_or_else(std::env::temp_dir); | ||
| // SAFETY: getuid has no failure mode. | ||
| dir.join(format!("openresearch-app-{}.sock", unsafe { | ||
| libc::getuid() | ||
| })) |
There was a problem hiding this comment.
Shared socket can block launches
If XDG_RUNTIME_DIR is unset, the app uses a predictable socket name in the shared temporary directory. Another local user can bind that name first; the app then treats the connection as an existing instance and exits without opening a window. Use a user-owned socket location and verify ownership before accepting the connection.
How this was verified: The fallback uses a predictable pathname in the shared temporary directory, and any successful connection causes the app to exit its launch path.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/app.rs
Line: 361-367
Comment:
**Shared socket can block launches**
If `XDG_RUNTIME_DIR` is unset, the app uses a predictable socket name in the shared temporary directory. Another local user can bind that name first; the app then treats the connection as an existing instance and exits without opening a window. Use a user-owned socket location and verify ownership before accepting the connection.
**How this was verified:** The fallback uses a predictable pathname in the shared temporary directory, and any successful connection causes the app to exit its launch path.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| .map_err(|e| anyhow!("Could not write {}: {}", staged.display(), e))?; | ||
| std::fs::set_permissions(staged, std::fs::Permissions::from_mode(0o755)) | ||
| .map_err(|e| anyhow!("Could not make {} executable: {}", staged.display(), e))?; | ||
| std::fs::rename(staged, appimage) |
There was a problem hiding this comment.
Updates replace symlinks instead
If the AppImage was launched through a symlink, this rename replaces the symlink rather than its target. The updater reports success and the launcher path runs the new version, but the AppImage at the intended installation path remains old. Resolve the target or reject symlinked installs before updating.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/updates/linux_app.rs
Line: 175
Comment:
**Updates replace symlinks instead**
If the AppImage was launched through a symlink, this rename replaces the symlink rather than its target. The updater reports success and the launcher path runs the new version, but the AppImage at the intended installation path remains old. Resolve the target or reject symlinked installs before updating.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // Beside the target, so the final move is a same-filesystem rename. | ||
| let staged = parent.join(format!( | ||
| ".OpenResearch-update-{}.AppImage", | ||
| uuid::Uuid::new_v4() | ||
| )); | ||
| let installed = install_staged(&staged, &bytes, appimage); | ||
| if installed.is_err() { | ||
| let _ = std::fs::remove_file(&staged); |
There was a problem hiding this comment.
Interrupted updates leave large files
An interrupted background update can leave a full .OpenResearch-update-*.AppImage beside the installation. Each attempt uses a new filename, while cleanup only handles an error returned by the current attempt, so repeated interruptions consume disk space. Sweep leftovers before staging another update, as the macOS updater does.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/updates/linux_app.rs
Line: 125-132
Comment:
**Interrupted updates leave large files**
An interrupted background update can leave a full `.OpenResearch-update-*.AppImage` beside the installation. Each attempt uses a new filename, while cleanup only handles an error returned by the current attempt, so repeated interruptions consume disk space. Sweep leftovers before staging another update, as the macOS updater does.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| let bytes = super::fetch_release_asset( | ||
| &manifest.tag, | ||
| &asset.asset, | ||
| // The AppImage carries WebKitGTK, so it is a large download. | ||
| Duration::from_secs(600), | ||
| ) | ||
| .await?; |
There was a problem hiding this comment.
This passes the WebKit-bundled AppImage through fetch_release_asset, which holds the entire download in memory before it is verified and written to disk. That adds substantial peak memory use and may prevent updates on memory-constrained desktops. Stream the download into the staging file while calculating its checksum.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/updates/linux_app.rs
Line: 106-112
Comment:
**Large downloads fill memory**
This passes the WebKit-bundled AppImage through `fetch_release_asset`, which holds the entire download in memory before it is verified and written to disk. That adds substantial peak memory use and may prevent updates on memory-constrained desktops. Stream the download into the staging file while calculating its checksum.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.# Conflicts: # ui/dist/assets/PdfPreview-BSSxQbi_.js # ui/dist/assets/PdfPreview-BqsPVk8K.js # ui/dist/assets/PdfPreview-CyjErspD.js # ui/dist/assets/index-5RWi1g1b.js # ui/dist/assets/index-WDCgqWTd.js # ui/dist/assets/index-ZrkI6iDM.js # ui/dist/index.html
Phase 3 of the desktop app, stacked on #439 (Windows) → #438 (macOS); retarget to
mainonce those merge. PDF previews depend on #444 (PDF.js), since WebKitGTK has no PDF viewer.What changes
desktopcargo feature (Linux only): optionaltao,wry,gtkandpng, so the static musl CLI builds stay GTK-free.build.rssetscfg(desktop_app)for macOS, Windows, and Linux with the feature.orx appon Linux, started by the AppImage's AppRun and sharing the Windows entry point (APP_ARG/launched_with_app_arg):PATHand related variables are adopted after the single-instance claim$XDG_RUNTIME_DIR; a second launch shows the running windowzenityorkdialog, and on stderrOpenResearch(so the window class is too), and each launch writes~/.local/share/applications/openresearch.desktopplus its icon, pointing at the AppImage's current path.TryExechides the entry once the AppImage is deleted. Without it GNOME shows a gear namedorx.ORX_HOST_*. The browser, file manager, editors, folder picker, dashboard terminals, agents and the update relaunch get them back, so host programs don't load the image's GTK modules and schemas. Relative project paths and the folder picker anchor to home, not the read-only mount.scripts/build-linux-appimage.sh,linux/):linuxdeploy,linuxdeploy-plugin-gtk,appimagetooland type2 runtime/usrrewritten to././inlibwebkit2gtk, AppRun runs from$APPDIR/usr); WebKit's helper processes get an RPATH into the image so they never load the host's librarieslibgiognutls) is bundled, andGIO_MODULE_DIRpoints only at the image's modules: the host's are built against a newer GLib than the bundled oneInstallChannel::AppImage(appimagein the dashboard).updates/linux_app.rsreadslinux-app.json(one sha256 per architecture), verifies the download, and renames it over the old file; production builds only. Restart execs the new$APPIMAGEon the same port. The macOS manifest fetch moved into a sharedfetch_app_manifest.linux app (x86_64|aarch64)onubuntu-22.04/ubuntu-22.04-arm: clippy and tests with--features desktop, release build, packaging, then a smoke test with WebKitGTK removed from the runner. It passes only when the dashboard answers, a visibleOpenResearch-class window appears without the load fallback, the desktop entry points at the AppImage, and every WebKit helper runs from and mapslibwebkit2gtkfrom the image and no host GIO modules. Uploaded asopenresearch-linux-<arch>-appimage.release-linux-app.ymlbuilds both architectures from the release commit and attaches the AppImages, thenlinux-app.json; gated onLINUX_APP_ENABLED, with write permission only in the publish job.docs/linux.md.Known gaps
GDK_BACKEND=x11).Test plan
cargo fmt --check,cargo clippy --all-targets -D warnings,cargo test --locked; UI typecheck, i18n and style lint, UI tests;ui/distrebuiltwindow.confirmshows a dialogorx; the shell PATH is adopted (e.g. an nvm-installedclaude)🤖 Generated with Claude Code