From dc4ca3670ec7aaba4d5bfad8267974b66f6aad0c Mon Sep 17 00:00:00 2001 From: Tee8z Date: Wed, 4 Nov 2026 15:26:10 -0500 Subject: [PATCH] feat(gpui): ship portable Linux packages: AppImage, arm64, and Nix Developers can now install the Voltage build from a release download without Nix, on any Linux, or prebuilt through Nix. - Build the .deb on Ubuntu 24.04 instead of 26.04. The 26.04 package required libc6 >= 2.43, so it did not install on Ubuntu 24.04 or Debian 13. - BUZZ_GPUI_APPIMAGE=1 also wraps the same build in an AppImage with the static type2 runtime (no host libfuse2). The AppImage carries the GStreamer plugins that Buzz.app bundles, with PulseAudio and ALSA in place of Core Audio. Its AppRun points GStreamer at them and uses a registry of its own. The matching GIO TLS backend and its dependencies support HTTPS. - check-appimage.sh runs the AppImage in Ubuntu 24.04, Debian 13 and Fedora 43 containers that have no GStreamer. Every ELF must resolve its libraries, the bundled Node must run the plugins and the provider, and --gst-check must pass for local media and a certificate-verified HTTPS clip. - The release workflow builds both packages on x86_64 and aarch64. - The flake's buzz-gpui-voltage-bin runs the pinned release AppImage in nixpkgs' appimageTools environment. NixOS needs no nix-ld, and the app does not compile. After each release, the release workflow opens a pull request that pins the new AppImages in nix/release.json. - In an AppImage, the buzz:// handler and the updater use the image file, not the temporary mount. The updater offers the AppImage asset for download. Unit tests cover these decisions directly. - dav1ddec is not required on Linux, because Ubuntu 24.04 packages no GStreamer AV1 decoder. - Point step 1 of voltage/remote-agents.md at the release downloads and the Nix package. Refs #87 Signed-off-by: Tee8z --- .../workflows/voltage-buzz-gpui-release.yml | 88 ++++++++++- apps/buzz-gpui/README.md | 48 +++++- apps/buzz-gpui/crates/app/src/appimage.rs | 78 ++++++++++ apps/buzz-gpui/crates/app/src/deep_links.rs | 50 +++++- apps/buzz-gpui/crates/app/src/gst_bundle.rs | 3 + apps/buzz-gpui/crates/app/src/main.rs | 1 + .../app/src/updates/fixtures/release.json | 9 ++ .../crates/app/src/updates/install.rs | 50 +++++- .../crates/app/src/updates/release.rs | 50 +++++- apps/buzz-gpui/flake.nix | 16 +- apps/buzz-gpui/nix/appimage.nix | 44 ++++++ apps/buzz-gpui/nix/release.json | 1 + apps/buzz-gpui/scripts/build-appimage.sh | 142 ++++++++++++++++++ apps/buzz-gpui/scripts/check-appimage.sh | 122 +++++++++++++++ apps/buzz-gpui/scripts/package-deb.sh | 44 ++++-- apps/buzz-gpui/scripts/pin-nix-release.py | 39 +++++ voltage/remote-agents.md | 37 ++++- 17 files changed, 779 insertions(+), 43 deletions(-) create mode 100644 apps/buzz-gpui/crates/app/src/appimage.rs create mode 100644 apps/buzz-gpui/nix/appimage.nix create mode 100644 apps/buzz-gpui/nix/release.json create mode 100755 apps/buzz-gpui/scripts/build-appimage.sh create mode 100755 apps/buzz-gpui/scripts/check-appimage.sh create mode 100644 apps/buzz-gpui/scripts/pin-nix-release.py diff --git a/.github/workflows/voltage-buzz-gpui-release.yml b/.github/workflows/voltage-buzz-gpui-release.yml index 96a9fc0714d..40c640b346a 100644 --- a/.github/workflows/voltage-buzz-gpui-release.yml +++ b/.github/workflows/voltage-buzz-gpui-release.yml @@ -18,6 +18,10 @@ on: - apps/buzz-gpui/.github/actions/** - apps/buzz-gpui/scripts/package-* - apps/buzz-gpui/scripts/build-package.sh + - apps/buzz-gpui/scripts/*-appimage.sh + # What the AppImage and --gst-check lanes exercise in the app. + - apps/buzz-gpui/crates/app/src/appimage.rs + - apps/buzz-gpui/crates/app/src/gst_bundle.rs - apps/buzz-gpui/scripts/check-remote-provider.mjs - apps/buzz-gpui/scripts/check-remote-provider.test.mjs - apps/buzz-gpui/scripts/test-package-build.py @@ -79,23 +83,40 @@ jobs: echo "version=$upstream+voltage.$(( ${last:-0} + 1 ))" >> "$GITHUB_OUTPUT" linux: - name: GPUI Linux x86_64 (.deb) + name: GPUI Linux ${{ matrix.arch }} (.deb, .AppImage) needs: [version, package-contract] - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: x86_64 + - runner: ubuntu-24.04-arm + arch: aarch64 env: BUZZ_GPUI_RELEASE_VERSION: ${{ needs.version.outputs.version }} + BUZZ_GPUI_APPIMAGE: "1" + defaults: + run: + working-directory: apps/buzz-gpui steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - # Builds inside ubuntu:26.04, so the package links Ubuntu's libraries. + # Builds inside ubuntu:24.04, so the packages link Ubuntu's libraries + # and need only its glibc. - run: scripts/package-deb.sh - working-directory: apps/buzz-gpui + # The AppImage runs on other distributions, with only what it carries + # and the desktop libraries every system has. + - run: scripts/check-appimage.sh dist/*.AppImage - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: buzz-gpui-linux-x86_64 - path: apps/buzz-gpui/dist/*.deb + name: buzz-gpui-linux-${{ matrix.arch }} + path: | + apps/buzz-gpui/dist/*.deb + apps/buzz-gpui/dist/*.AppImage if-no-files-found: error macos: @@ -214,3 +235,58 @@ jobs: --title "Buzz GPUI ${TAG#v}" --notes "$notes" --draft gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + + # Nix users install a release through the flake's buzz-gpui-voltage-bin, + # which pins the release's AppImages in apps/buzz-gpui/nix/release.json. + # main takes changes only through reviewed pull requests, so this proposes + # the new pin as one. A job of its own, so a failure here can be re-run + # without publishing the release again. + nix-pin: + name: GPUI pin the release for Nix + if: github.event_name == 'workflow_dispatch' + needs: [version, release] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + pattern: buzz-gpui-linux-* + merge-multiple: true + - run: python3 apps/buzz-gpui/scripts/pin-nix-release.py "$VERSION" "$GITHUB_REPOSITORY" dist + env: + VERSION: ${{ needs.version.outputs.version }} + # A commit made through the API is signed by GitHub, as main requires. + - env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.version.outputs.version }} + run: | + branch="chore/gpui-nix-pin-v$VERSION" + title="chore(gpui): pin Buzz GPUI $VERSION for Nix" + # A re-run starts the branch, which only this job writes, again. + gh api -X DELETE "repos/$GITHUB_REPOSITORY/git/refs/heads/$branch" >/dev/null 2>&1 || true + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/heads/$branch" -f sha="$GITHUB_SHA" >/dev/null + jq -n \ + --arg repository "$GITHUB_REPOSITORY" --arg branch "$branch" --arg head "$GITHUB_SHA" \ + --arg headline "$title" \ + --arg contents "$(base64 -w0 apps/buzz-gpui/nix/release.json)" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: { input: { + branch: { repositoryNameWithOwner: $repository, branchName: $branch }, + expectedHeadOid: $head, + message: { + headline: $headline, + body: "Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + }, + fileChanges: { additions: [ { path: "apps/buzz-gpui/nix/release.json", contents: $contents } ] } + } } + }' | gh api graphql --input - >/dev/null + gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" \ + --body "Points the flake's \`buzz-gpui-voltage-bin\` at the AppImages of [v$VERSION]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v$VERSION). Generated by the release workflow." diff --git a/apps/buzz-gpui/README.md b/apps/buzz-gpui/README.md index 023c0a6cd9c..bdc9563e678 100644 --- a/apps/buzz-gpui/README.md +++ b/apps/buzz-gpui/README.md @@ -17,8 +17,10 @@ See [Move existing agents to Remote workspace](docs/remote-agent-migration.md) f sudo apt install ./buzz-gpui_0.1.0-1_amd64.deb ``` -Build the package with `scripts/package-deb.sh`. It builds inside an -`ubuntu:26.04` container and writes `dist/buzz-gpui__amd64.deb`. +The package installs on Ubuntu 24.04, Debian 13 and later, on x86_64 +(`amd64`) or ARM (`arm64`). Build it with `scripts/package-deb.sh`. It builds +inside an `ubuntu:24.04` container for the machine's own architecture and +writes `dist/buzz-gpui__.deb`. On first start the app reads your identity from the keyring. It uses the `dev.local.buzz.foundation.identity` entry. If that entry is empty, it copies @@ -35,6 +37,43 @@ tray icons through the AppIndicator extension, which Ubuntu turns on. Agents on the laptop run inside the app. Their keys are kept in the system keyring, and they stop when the app quits. +## Laptop: install the app (other Linux) + +`Buzz--linux-x86_64.AppImage` (or `-aarch64`) runs on any Linux +with glibc 2.39 or later, for example Fedora 40 and later. It carries the +GStreamer libraries and plugins it plays audio and video with, plus the +GIO TLS backend for HTTPS media. It takes +the desktop libraries every system has (X11, fontconfig, ALSA) from the +system. Its static runtime does not require the host `libfuse2` library. + +```sh +chmod +x Buzz--linux-x86_64.AppImage +./Buzz--linux-x86_64.AppImage +``` + +If FUSE mounting is unavailable, extract and run it instead: + +```sh +./Buzz--linux-x86_64.AppImage --appimage-extract-and-run +``` + +It registers itself for `buzz://` links, and its update notice downloads +the next AppImage. Ubuntu's GStreamer has no AV1 decoder, so the AppImage +does not play AV1 video. + +Build it with `BUZZ_GPUI_APPIMAGE=1 scripts/package-deb.sh`, which writes +the AppImage beside the `.deb` from the same build. +`scripts/check-appimage.sh ` then runs it on Ubuntu, Debian and +Fedora containers that have no GStreamer or GIO TLS backend: every library +it loads must be in the image or a desktop library, and it must play local +and HTTPS media. + +On NixOS, which has no `/lib64` loader, install the flake's +`buzz-gpui-voltage-bin` instead. It runs the release AppImage in nixpkgs' +`appimageTools` environment (`nix/appimage.nix`). `nix/release.json` pins +the AppImages; after each release, Voltage's release workflow opens a pull +request with the new pin (`scripts/pin-nix-release.py`). + ## Laptop: install the app (macOS) The macOS build is not signed or notarized. It carries the GStreamer @@ -87,8 +126,9 @@ The ones you may notice: Code there; install them yourself and Buzz finds them on `PATH`. - On Windows, hosts and `buzzd` listen and connect over `tcp://` only, not `unix://`. -- HEIC avatars need `ffmpeg`: `brew install ffmpeg` on macOS, and on - Windows an `ffmpeg.exe` on `PATH`. +- HEIC avatars need `ffmpeg`: `brew install ffmpeg` on macOS, on Windows + an `ffmpeg.exe` on `PATH`, and with the AppImage the distribution's + `ffmpeg` package. ### Builds diff --git a/apps/buzz-gpui/crates/app/src/appimage.rs b/apps/buzz-gpui/crates/app/src/appimage.rs new file mode 100644 index 00000000000..39ef2b86dd6 --- /dev/null +++ b/apps/buzz-gpui/crates/app/src/appimage.rs @@ -0,0 +1,78 @@ +//! Whether this copy runs from an AppImage, and which file that is. +//! +//! The AppImage runtime mounts the image at a temporary directory, sets +//! `APPDIR` to it and `APPIMAGE` to the image file, then starts the binary +//! inside. The mount goes away when the app exits, so whatever outlives this +//! process (the `buzz://` handler, the next version's download) names the +//! image, never the running binary. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The AppImage file this process runs from, if it runs from one. +pub fn path() -> Option { + let exe = std::env::current_exe().ok()?; + // The executable's path is canonical; the mount's is as the runtime + // built it from TMPDIR, which can run through a symlink. + let appdir = std::env::var_os("APPDIR") + .map(|dir| std::fs::canonicalize(&dir).map_or(dir, PathBuf::into_os_string)); + from_env(std::env::var_os("APPIMAGE"), appdir, &exe) +} + +/// `appimage`, when `exe` lies inside `appdir`. A program started from +/// inside some other AppImage (a terminal, say) inherits both variables, +/// but its binary is elsewhere. +fn from_env(appimage: Option, appdir: Option, exe: &Path) -> Option { + let appimage = PathBuf::from(appimage?); + let appdir = PathBuf::from(appdir?); + (appimage.is_absolute() && appdir.is_absolute() && exe.starts_with(&appdir)).then_some(appimage) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(appimage: Option<&str>, appdir: Option<&str>, exe: &str) -> Option { + from_env( + appimage.map(Into::into), + appdir.map(Into::into), + Path::new(exe), + ) + } + + #[test] + fn names_the_image_the_binary_runs_from() { + assert_eq!( + env( + Some("/home/me/Apps/Buzz.AppImage"), + Some("/tmp/.mount_BuzzAb12"), + "/tmp/.mount_BuzzAb12/usr/bin/buzz-gpui", + ), + Some(PathBuf::from("/home/me/Apps/Buzz.AppImage")) + ); + } + + #[test] + fn ignores_variables_inherited_from_another_image() { + assert_eq!( + env( + Some("/home/me/Apps/Terminal.AppImage"), + Some("/tmp/.mount_TermCd34"), + "/usr/bin/buzz-gpui", + ), + None + ); + } + + #[test] + fn needs_both_variables_as_absolute_paths() { + let exe = "/tmp/.mount_BuzzAb12/usr/bin/buzz-gpui"; + assert_eq!(env(None, Some("/tmp/.mount_BuzzAb12"), exe), None); + assert_eq!(env(Some("/home/me/Buzz.AppImage"), None, exe), None); + assert_eq!( + env(Some("Buzz.AppImage"), Some("/tmp/.mount_BuzzAb12"), exe), + None + ); + assert_eq!(env(Some("/home/me/Buzz.AppImage"), Some(""), exe), None); + } +} diff --git a/apps/buzz-gpui/crates/app/src/deep_links.rs b/apps/buzz-gpui/crates/app/src/deep_links.rs index 0f3da65220b..2b03e4d1ac8 100644 --- a/apps/buzz-gpui/crates/app/src/deep_links.rs +++ b/apps/buzz-gpui/crates/app/src/deep_links.rs @@ -107,12 +107,9 @@ pub fn register_scheme() { /// Packaged installs register through their desktop file. #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn register_desktop_file() { - let Ok(exe) = std::env::current_exe() else { + let Some(exe) = handler_target(crate::appimage::path(), std::env::current_exe().ok()) else { return; }; - if exe.starts_with("/usr") || exe.starts_with("/opt") || exe.starts_with("/nix/store") { - return; - } let Some(applications) = dirs::data_dir().map(|dir| dir.join("applications")) else { return; }; @@ -144,6 +141,25 @@ fn register_desktop_file() { } } +/// What `buzz://` links should start: the AppImage file when this copy runs +/// from one, as its binary is in a mount that goes away when it exits; +/// otherwise this binary, unless a package installed it with a desktop file +/// of its own. +#[cfg(any(target_os = "linux", target_os = "freebsd", test))] +fn handler_target( + appimage: Option, + exe: Option, +) -> Option { + if appimage.is_some() { + return appimage; + } + let exe = exe?; + let packaged = ["/usr", "/opt", "/nix/store"] + .iter() + .any(|dir| exe.starts_with(dir)); + (!packaged).then_some(exe) +} + /// The user desktop file that hands `buzz://` links to this binary. #[cfg(any(target_os = "linux", target_os = "freebsd"))] const HANDLER: &str = "buzz-gpui-handler.desktop"; @@ -252,6 +268,32 @@ mod tests { assert!(handler_entry("/a/100%$x").contains("Exec=\"/a/100%%\\$x\" %u")); } + #[test] + fn links_start_the_appimage_rather_than_its_mount() { + use std::path::PathBuf; + let path = |p: &str| Some(PathBuf::from(p)); + let mount = path("/tmp/.mount_BuzzAb12/usr/bin/buzz-gpui"); + assert_eq!( + handler_target(path("/home/me/Buzz.AppImage"), mount.clone()), + path("/home/me/Buzz.AppImage") + ); + // A package's desktop file handles links, but not for an AppImage. + assert_eq!( + handler_target(path("/opt/Buzz.AppImage"), mount), + path("/opt/Buzz.AppImage") + ); + assert_eq!(handler_target(None, path("/usr/bin/buzz-gpui")), None); + assert_eq!( + handler_target(None, path("/nix/store/x/bin/buzz-gpui")), + None + ); + assert_eq!( + handler_target(None, path("/home/me/bin/buzz-gpui")), + path("/home/me/bin/buzz-gpui") + ); + assert_eq!(handler_target(None, None), None); + } + #[test] fn the_windows_protocol_runs_this_binary() { assert_eq!( diff --git a/apps/buzz-gpui/crates/app/src/gst_bundle.rs b/apps/buzz-gpui/crates/app/src/gst_bundle.rs index 7130fad09bc..b04592f9f04 100644 --- a/apps/buzz-gpui/crates/app/src/gst_bundle.rs +++ b/apps/buzz-gpui/crates/app/src/gst_bundle.rs @@ -133,6 +133,9 @@ const ELEMENTS: &[&str] = &[ "mpg123audiodec", "vp8dec", "vp9dec", + // Ubuntu 24.04, which the Linux packages build on, packages no GStreamer + // AV1 decoder; AV1 plays on Linux only where the system adds one. + #[cfg(not(target_os = "linux"))] "dav1ddec", "opusdec", "vorbisdec", diff --git a/apps/buzz-gpui/crates/app/src/main.rs b/apps/buzz-gpui/crates/app/src/main.rs index 4ae0a4cce88..b0212d60865 100644 --- a/apps/buzz-gpui/crates/app/src/main.rs +++ b/apps/buzz-gpui/crates/app/src/main.rs @@ -5,6 +5,7 @@ mod agent_activity; mod agent_plugins; mod agents2; mod appearance; +mod appimage; mod atomic_file; mod buzz_mcp; mod channel_templates; diff --git a/apps/buzz-gpui/crates/app/src/updates/fixtures/release.json b/apps/buzz-gpui/crates/app/src/updates/fixtures/release.json index f6f47533559..a31e0b0e236 100644 --- a/apps/buzz-gpui/crates/app/src/updates/fixtures/release.json +++ b/apps/buzz-gpui/crates/app/src/updates/fixtures/release.json @@ -51,6 +51,15 @@ "size": 24117248, "browser_download_url": "https://github.com/benthecarman/buzz-gpui/releases/download/v0.1.1/buzz-gpui_0.1.1-1_amd64.deb" }, + { + "url": "https://api.github.com/repos/benthecarman/buzz-gpui/releases/assets/300000007", + "id": 300000007, + "name": "Buzz-0.1.1-linux-x86_64.AppImage", + "content_type": "application/vnd.appimage", + "state": "uploaded", + "size": 98566144, + "browser_download_url": "https://github.com/benthecarman/buzz-gpui/releases/download/v0.1.1/Buzz-0.1.1-linux-x86_64.AppImage" + }, { "url": "https://api.github.com/repos/benthecarman/buzz-gpui/releases/assets/300000005", "id": 300000005, diff --git a/apps/buzz-gpui/crates/app/src/updates/install.rs b/apps/buzz-gpui/crates/app/src/updates/install.rs index 4840cab30fb..c77183fc857 100644 --- a/apps/buzz-gpui/crates/app/src/updates/install.rs +++ b/apps/buzz-gpui/crates/app/src/updates/install.rs @@ -8,9 +8,12 @@ pub enum InstallKind { /// Installed from the .deb: download, verify and install in place. Deb, - /// A packaged release elsewhere (macOS, Windows): the browser - /// downloads the new one. - #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] + /// A packaged release elsewhere (macOS, Windows, a Linux AppImage): + /// the browser downloads the new one. + #[cfg_attr( + not(any(target_os = "linux", target_os = "macos", target_os = "windows")), + allow(dead_code) + )] Browser, /// Built from source: only say that a release exists. Dev, @@ -39,10 +42,25 @@ impl InstallKind { #[cfg(target_os = "linux")] fn detect_release() -> Self { - let exe = std::env::current_exe().ok(); - let from_deb = exe.is_some_and(|exe| running_binary(&exe) == DEB_BINARY) - && std::fs::read_to_string(DEB_FILE_LIST).is_ok_and(|list| lists_binary(&list)); - if from_deb { Self::Deb } else { Self::Dev } + Self::linux_release(crate::appimage::path().is_some(), || { + let exe = std::env::current_exe().ok(); + exe.is_some_and(|exe| running_binary(&exe) == DEB_BINARY) + && std::fs::read_to_string(DEB_FILE_LIST).is_ok_and(|list| lists_binary(&list)) + }) + } + + /// A Linux release: an AppImage downloads the next one, even with the + /// .deb also installed; otherwise only the .deb's own binary updates in + /// place. + #[cfg(target_os = "linux")] + fn linux_release(from_appimage: bool, from_deb: impl FnOnce() -> bool) -> Self { + if from_appimage { + Self::Browser + } else if from_deb() { + Self::Deb + } else { + Self::Dev + } } #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -194,6 +212,24 @@ mod tests { use core::prelude::v1::test; use std::path::Path; + #[cfg(target_os = "linux")] + #[test] + fn an_appimage_updates_through_the_browser() { + assert_eq!( + InstallKind::linux_release(true, || true), + InstallKind::Browser + ); + assert_eq!( + InstallKind::linux_release(true, || false), + InstallKind::Browser + ); + assert_eq!(InstallKind::linux_release(false, || true), InstallKind::Deb); + assert_eq!( + InstallKind::linux_release(false, || false), + InstallKind::Dev + ); + } + #[test] fn installs_through_apt_with_a_prompt() { let package = Path::new("/home/me/.cache/buzz-gpui/updates/buzz-gpui_0.1.1-1_amd64.deb"); diff --git a/apps/buzz-gpui/crates/app/src/updates/release.rs b/apps/buzz-gpui/crates/app/src/updates/release.rs index 514058042dd..2ab67a611af 100644 --- a/apps/buzz-gpui/crates/app/src/updates/release.rs +++ b/apps/buzz-gpui/crates/app/src/updates/release.rs @@ -75,6 +75,8 @@ pub enum Platform { MacOs { arch: &'static str }, /// `Buzz--windows-.zip`. Windows { arch: &'static str }, + /// `Buzz--linux-.AppImage`. + AppImage { arch: &'static str }, /// No package is published for it. Other, } @@ -82,7 +84,21 @@ pub enum Platform { impl Platform { /// The running platform. pub fn current() -> Self { - Self::of(std::env::consts::OS, std::env::consts::ARCH) + Self::detect( + std::env::consts::OS, + std::env::consts::ARCH, + crate::appimage::path().is_some(), + ) + } + + /// The platform of a copy on `os` and `arch`, as `std::env::consts` + /// names them. A copy running from an AppImage updates to the next one. + pub fn detect(os: &str, arch: &str, from_appimage: bool) -> Self { + match (os, arch, from_appimage) { + ("linux", "x86_64", true) => Self::AppImage { arch: "x86_64" }, + ("linux", "aarch64", true) => Self::AppImage { arch: "aarch64" }, + _ => Self::of(os, arch), + } } pub fn of(os: &str, arch: &str) -> Self { @@ -108,6 +124,9 @@ impl Platform { Self::Windows { arch } => { name.starts_with("Buzz-") && name.ends_with(&format!("-windows-{arch}.zip")) } + Self::AppImage { arch } => { + name.starts_with("Buzz-") && name.ends_with(&format!("-linux-{arch}.AppImage")) + } Self::Other => false, } } @@ -191,7 +210,7 @@ mod tests { assert_eq!(release.version(), Some(v("0.1.1"))); assert!(!release.draft && !release.prerelease); assert!(release.body.as_deref().unwrap().contains("What's Changed")); - assert_eq!(release.assets.len(), 6); + assert_eq!(release.assets.len(), 7); assert_eq!( release.asset(CHECKSUMS).unwrap().browser_download_url, "https://github.com/benthecarman/buzz-gpui/releases/download/v0.1.1/SHA256SUMS" @@ -272,6 +291,33 @@ mod tests { assert_eq!(name("freebsd", "x86_64"), None); } + #[test] + fn an_appimage_picks_the_appimage() { + let release = release(); + let name = |os, arch, from_appimage| { + release + .asset_for(Platform::detect(os, arch, from_appimage)) + .map(|asset| asset.name.as_str()) + }; + assert_eq!( + name("linux", "x86_64", true), + Some("Buzz-0.1.1-linux-x86_64.AppImage") + ); + assert_eq!( + name("linux", "x86_64", false), + Some("buzz-gpui_0.1.1-1_amd64.deb") + ); + assert_eq!( + Platform::detect("linux", "aarch64", true), + Platform::AppImage { arch: "aarch64" } + ); + assert_eq!(Platform::detect("linux", "riscv64", true), Platform::Other); + assert_eq!( + name("macos", "aarch64", true), + Some("Buzz-0.1.1-macos-arm64.dmg") + ); + } + #[test] fn reads_sha256sum_output() { let sums = "\ diff --git a/apps/buzz-gpui/flake.nix b/apps/buzz-gpui/flake.nix index a1528bf329c..8bcc1de8ca8 100644 --- a/apps/buzz-gpui/flake.nix +++ b/apps/buzz-gpui/flake.nix @@ -229,8 +229,20 @@ platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin; }; }); + # The Voltage build without compiling it: the latest release's + # AppImage, which the release workflow pins in nix/release.json. + release = builtins.fromJSON (builtins.readFile ./nix/release.json); + releaseAsset = (release.assets or { }).${system} or null; + # nixpkgs.lib, not pkgs.lib: flake-utils reads every system's output + # names, and pkgs fails to evaluate on systems nixpkgs dropped. + voltageBin = nixpkgs.lib.optionalAttrs (releaseAsset != null) { + packages.buzz-gpui-voltage-bin = pkgs.callPackage ./nix/appimage.nix { + inherit (release) version; + inherit (releaseAsset) url hash; + }; + }; in - { + nixpkgs.lib.recursiveUpdate { # The dependencies build in their own derivation, so CI can keep # them and rebuild only this workspace's crates. packages.buzzd = craneLib.buildPackage (buzzdArgs // { @@ -262,5 +274,5 @@ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; }; }; - }); + } voltageBin); } diff --git a/apps/buzz-gpui/nix/appimage.nix b/apps/buzz-gpui/nix/appimage.nix new file mode 100644 index 00000000000..fa8d1352505 --- /dev/null +++ b/apps/buzz-gpui/nix/appimage.nix @@ -0,0 +1,44 @@ +# The Voltage build from a release: the AppImage that the release workflow +# publishes, run in the FHS environment of nixpkgs' appimageTools, so Nix +# users install it without compiling it. The AppImage carries the app, its +# provider, Node, Buzz's plugins and GStreamer. The environment supplies +# the desktop libraries the AppImage leaves to the system. +{ lib, appimageTools, fetchurl, version, url, hash, src ? fetchurl { inherit url hash; } }: +let + pname = "buzz-gpui"; + contents = appimageTools.extractType2 { inherit pname version src; }; +in +appimageTools.wrapType2 { + inherit pname version src; + extraPkgs = pkgs: with pkgs; [ + alsa-lib + dbus + fontconfig + freetype + fribidi + harfbuzz + libdrm + libgpg-error + libGL + libkrb5 + libx11 + libxcb + libxkbcommon + vulkan-loader + wayland + ]; + extraInstallCommands = '' + install -Dm644 ${contents}/usr/share/applications/buzz-gpui.desktop -t $out/share/applications + for size in 128 256 512; do + install -Dm644 ${contents}/usr/share/icons/hicolor/''${size}x''${size}/apps/buzz-gpui.png \ + -t $out/share/icons/hicolor/''${size}x''${size}/apps + done + ''; + meta = { + description = "Buzz desktop app, Voltage build, from its release AppImage"; + homepage = "https://github.com/voltagecloud/buzz/releases"; + mainProgram = "buzz-gpui"; + platforms = [ "x86_64-linux" "aarch64-linux" ]; + sourceProvenance = [ lib.sourceTypes.binaryNativeCode ]; + }; +} diff --git a/apps/buzz-gpui/nix/release.json b/apps/buzz-gpui/nix/release.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/apps/buzz-gpui/nix/release.json @@ -0,0 +1 @@ +{} diff --git a/apps/buzz-gpui/scripts/build-appimage.sh b/apps/buzz-gpui/scripts/build-appimage.sh new file mode 100755 index 00000000000..693b59d8332 --- /dev/null +++ b/apps/buzz-gpui/scripts/build-appimage.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Wraps the release build in an AppImage, the package for Linux systems that +# do not take the .deb. Runs as root inside the Ubuntu container of +# scripts/package-deb.sh (BUZZ_GPUI_APPIMAGE=1), after the build, so the +# AppImage needs the glibc of that Ubuntu or later. +# +# The image carries the GStreamer the app plays media with, as Buzz.app does: +# the plugins below, the plugin scanner and every library they load that the +# AppImage exclude list does not leave to the system. AppRun points GStreamer +# at them, as crates/app/src/gst_bundle.rs does inside Buzz.app. +# +# Usage: scripts/build-appimage.sh +# -> dist/Buzz--linux-.AppImage +set -euo pipefail +version="$1" +linuxdeploy_tag=1-alpha-20251107-1 +appimagetool_tag=1.9.1 +runtime_tag=20251108 +arch="$(uname -m)" +case "$arch" in + x86_64) + linuxdeploy_sha256=c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d + appimagetool_sha256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + runtime_sha256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + linuxdeploy_sha256=620095110d693282b8ebeb244a95b5e911cf8f65f76c88b4b47d16ae6346fcff + appimagetool_sha256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + runtime_sha256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + *) echo "No AppImage tools for $arch." >&2; exit 1 ;; +esac + +# What playback, sounds and voice notes use: the set that +# scripts/bundle-macos-gstreamer.py bundles, with PulseAudio and ALSA in +# place of Core Audio, and without dav1d, which Ubuntu does not package. +plugins=( + app audioconvert audioparsers audioresample autodetect coreelements + pulseaudio alsa isomp4 libav matroska mpg123 ogg opus playback soup + typefindfunctions videoconvertscale videoparsersbad volume vorbis vpx + wavparse flac audiotestsrc rawparse id3demux pbtypes gio tcp + adaptivedemux2 dash hls +) + +here="$(cd "$(dirname "$0")/.." && pwd)" +cd "$here" +release="${CARGO_TARGET_DIR:-$here/target}/release" +system="/usr/lib/$arch-linux-gnu" + +apt-get install -y -qq --no-install-recommends \ + file patchelf gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-libav gstreamer1.0-alsa \ + gstreamer1.0-pulseaudio libsoup-3.0-0 glib-networking >/dev/null + +tools="$(mktemp -d)" +fetch() { + curl -sSfL -o "$tools/$1" "$2" + echo "$3 $tools/$1" | sha256sum -c --quiet + chmod +x "$tools/$1" +} +fetch linuxdeploy \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/$linuxdeploy_tag/linuxdeploy-$arch.AppImage" \ + "$linuxdeploy_sha256" +fetch appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/$appimagetool_tag/appimagetool-$arch.AppImage" \ + "$appimagetool_sha256" +# The static runtime: the AppImage then runs without libfuse2, which recent +# distributions no longer install. +fetch runtime \ + "https://github.com/AppImage/type2-runtime/releases/download/$runtime_tag/runtime-$arch" \ + "$runtime_sha256" +# The tools are AppImages themselves, and a container has no FUSE. +export APPIMAGE_EXTRACT_AND_RUN=1 + +appdir="$tools/Buzz.AppDir" +mkdir -p "$appdir/usr/bin" "$appdir/usr/lib/gstreamer-1.0" "$appdir/usr/libexec/gstreamer-1.0" \ + "$appdir/usr/lib/gio/modules" +cp "$release/buzz-gpui" "$appdir/usr/bin/" +executables=(--executable "$appdir/usr/bin/buzz-gpui") +if [ "${BUZZ_GPUI_VOLTAGE:-0}" = 1 ]; then + # The Sandbox host finds its provider beside the app. + cp "$release/buzz-backend-relay" "$appdir/usr/bin/" + executables+=(--executable "$appdir/usr/bin/buzz-backend-relay") +fi +for plugin in "${plugins[@]}"; do + cp "$system/gstreamer-1.0/libgst$plugin.so" "$appdir/usr/lib/gstreamer-1.0/" +done +cp "$system/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner" "$appdir/usr/libexec/gstreamer-1.0/" +# GIO loads its TLS backend dynamically; libsoup's ELF dependencies do not +# include it. Keep it from the same distribution as the bundled GLib. +cp "$system/gio/modules/libgiognutls.so" "$appdir/usr/lib/gio/modules/" +"$system/glib-2.0/gio-querymodules" "$appdir/usr/lib/gio/modules" +icons=() +for size in 128 256 512; do + mkdir -p "$tools/icons/$size" + cp "assets/icons/buzz-$size.png" "$tools/icons/$size/buzz-gpui.png" + icons+=(--icon-file "$tools/icons/$size/buzz-gpui.png") +done + +# linuxdeploy copies in the libraries the executables, plugins and scanner +# load. The soup plugin opens libsoup by name, so it is named here. +"$tools/linuxdeploy" --appdir "$appdir" "${executables[@]}" \ + --deploy-deps-only "$appdir/usr/lib/gstreamer-1.0" \ + --deploy-deps-only "$appdir/usr/libexec/gstreamer-1.0" \ + --deploy-deps-only "$appdir/usr/lib/gio/modules" \ + --library "$system/libsoup-3.0.so.0" \ + --desktop-file packaging/linux/buzz-gpui.desktop "${icons[@]}" +# Plugins and the scanner find those libraries in usr/lib. +for plugin in "$appdir"/usr/lib/gstreamer-1.0/*.so; do + patchelf --set-rpath "\$ORIGIN/.." "$plugin" +done +patchelf --set-rpath "\$ORIGIN/../../lib" "$appdir/usr/libexec/gstreamer-1.0/gst-plugin-scanner" +patchelf --set-rpath "\$ORIGIN/../.." "$appdir/usr/lib/gio/modules/libgiognutls.so" + +# Node and Buzz's plugins, staged by scripts/package-deb.sh, where the app +# looks beside its binary. They go in after linuxdeploy: the Node release +# runs on any glibc this image does, and must not be rewritten. +bundle="${CARGO_TARGET_DIR:-$here/target}/release/bundle" +mkdir -p "$appdir/usr/lib/buzz-gpui" "$appdir/usr/share/buzz-gpui" +cp -r "$bundle/node" "$appdir/usr/lib/buzz-gpui/" +cp -r "$bundle/plugins" "$appdir/usr/share/buzz-gpui/" + +# AppRun: GStreamer and GIO modules only from the image, with a registry of +# its own, so a system GStreamer is never mixed in or has its registry rewritten. +rm -f "$appdir/AppRun" +cat >"$appdir/AppRun" <-linux-.AppImage +set -euo pipefail +here="$(cd "$(dirname "$0")/.." && pwd)" +appimage="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +media="$(mktemp -d)" +trap 'rm -rf "$media"' EXIT +cp "$here"/crates/relay/tests/fixtures/clip.{webm,mov} "$here/assets/sounds/ping.mp3" "$media/" +# A private test CA, trusted only in the disposable containers. Playback must +# verify the HTTPS certificate; no network service or disabled TLS checks. +openssl req -x509 -newkey rsa:2048 -noenc -days 1 \ + -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1 \ + -keyout "$media/key.pem" -out "$media/ca.crt" >/dev/null 2>&1 + +# The oldest supported Ubuntu (the build's own), and two other families, +# each with the desktop libraries the exclude list leaves to the system. +debian_baseline=( + libx11-6 libx11-xcb1 libxcb1 libxcb-dri3-0 libdrm2 libgl1 libegl1 libvulkan1 + libwayland-client0 libfontconfig1 libfreetype6 libharfbuzz0b libfribidi0 + libasound2t64 libgpg-error0 libcom-err2 libdbus-1-3 ca-certificates +) +fedora_baseline=( + libX11 libX11-xcb libxcb libdrm mesa-libGL mesa-libEGL vulkan-loader + libwayland-client fontconfig freetype harfbuzz fribidi alsa-lib + libgpg-error libcom_err dbus-libs ca-certificates +) +apt="apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends ${debian_baseline[*]}" +systems=( + "ubuntu:24.04|$apt" + "debian:13|$apt" + "fedora:43|dnf install -y -q --setopt=install_weak_deps=False ${fedora_baseline[*]}" +) + +# Runs in each container, as root. +inside=$(cat <<'EOF' +set -eu +sh -c "$INSTALL" >/dev/null +if ldconfig -p | grep -q libgstreamer-1.0; then + echo "FAIL this system has a GStreamer of its own" + exit 1 +fi +if find /usr/lib /usr/lib64 -name libgiognutls.so -print 2>/dev/null | grep -q .; then + echo "FAIL this system has a GIO TLS backend of its own" + exit 1 +fi +if command -v update-ca-certificates >/dev/null; then + cp /check/media/ca.crt /usr/local/share/ca-certificates/buzz-check.crt + update-ca-certificates >/dev/null 2>&1 +else + cp /check/media/ca.crt /etc/pki/ca-trust/source/anchors/buzz-check.crt + update-ca-trust +fi +cd /tmp +/check/Buzz.AppImage --appimage-extract >/dev/null +root=/tmp/squashfs-root +missing=$(find "$root" -type f | while read -r file; do + if [ "$(head -c4 "$file" | tail -c3)" = ELF ]; then + ldd "$file" 2>/dev/null | awk -v file="${file#"$root"/}" '/not found/ { print file ": " $1 }' + fi +done) +if [ -n "$missing" ]; then + echo "FAIL libraries not found:" + echo "$missing" + exit 1 +fi +echo "every ELF finds its libraries" +node="$root/usr/lib/buzz-gpui/node/bin/node" +"$node" /check/scripts/check-plugins.mjs "$root/usr/share/buzz-gpui/plugins" +if [ -f "$root/usr/bin/buzz-backend-relay" ]; then + "$node" /check/scripts/check-remote-provider.mjs "$root/usr/bin/buzz-backend-relay" +fi +# Use the image's Node and only its standard library for the HTTPS fixture. +"$node" --input-type=module - <<'JS' & +import { createServer } from 'node:https'; +import { readFileSync, writeFileSync } from 'node:fs'; +const media = readFileSync('/check/media/clip.webm'); +createServer({ key: readFileSync('/check/media/key.pem'), cert: readFileSync('/check/media/ca.crt') }, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': media.length }); + response.end(media); + }).listen(8443, '127.0.0.1', () => writeFileSync('/tmp/https-ready', 'ready')); +JS +server=$! +trap 'kill "$server" 2>/dev/null || true' EXIT +attempt=0 +until [ -f /tmp/https-ready ]; do + if ! kill -0 "$server" 2>/dev/null || [ "$attempt" -ge 30 ]; then + echo "FAIL HTTPS fixture did not start" + exit 1 + fi + attempt=$((attempt + 1)) + sleep 1 +done +APPIMAGE_EXTRACT_AND_RUN=1 HOME=/tmp/home /check/Buzz.AppImage --gst-check \ + file:///check/media/clip.webm file:///check/media/ping.mp3 file:///check/media/clip.mov \ + https://127.0.0.1:8443/clip.webm +EOF +) + +for system in "${systems[@]}"; do + image="${system%%|*}" + echo "== $image" + docker run --rm \ + -v "$appimage:/check/Buzz.AppImage:ro" \ + -v "$media:/check/media:ro" \ + -v "$here/scripts:/check/scripts:ro" \ + -e INSTALL="${system#*|}" \ + "$image" sh -c "$inside" +done diff --git a/apps/buzz-gpui/scripts/package-deb.sh b/apps/buzz-gpui/scripts/package-deb.sh index 183348e7877..28a9dd1ab04 100755 --- a/apps/buzz-gpui/scripts/package-deb.sh +++ b/apps/buzz-gpui/scripts/package-deb.sh @@ -1,8 +1,14 @@ #!/usr/bin/env bash -# Builds the Ubuntu .deb inside an ubuntu:26.04 container, so the binary links -# against Ubuntu's libraries rather than Nix store paths. +# Builds the Ubuntu .deb inside an ubuntu:24.04 container, so the binary links +# against Ubuntu's libraries rather than Nix store paths, and needs only the +# glibc of Ubuntu 24.04 or Debian 13 and later. Builds for the architecture +# of the machine it runs on. # -# Usage: scripts/package-deb.sh -> dist/buzz-gpui__amd64.deb +# Usage: scripts/package-deb.sh -> dist/buzz-gpui__.deb +# +# BUZZ_GPUI_APPIMAGE=1 also wraps the same build in an AppImage, for other +# distributions: dist/Buzz--linux-.AppImage (see +# scripts/build-appimage.sh). # # BUZZ_GPUI_RELEASE_VERSION, when set, is the package version and the version # the app reports. @@ -11,27 +17,38 @@ # runs them, pinned below and checked against its published checksum. set -euo pipefail node_version=22.23.3 -node_sha256=df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de +case "$(uname -m)" in + x86_64) node_arch=x64 node_sha256=df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de ;; + aarch64) node_arch=arm64 node_sha256=a44aeb94849a299b22df10b9e622ec2f605c2183501bc40590705131de7c740f ;; + *) echo "No Node for $(uname -m)." >&2; exit 1 ;; +esac +image=ubuntu:24.04 here="$(cd "$(dirname "$0")/.." && pwd)" repo="$(cd "$here/../.." && pwd)" cache="$HOME/.cache/buzz-gpui-deb" -mkdir -p "$here/dist" "$cache/cargo" "$cache/rustup" "$cache/target" +# What one Ubuntu release builds links against its libraries, cargo-deb +# included; keep each release's apart. +cargo_home="$cache/cargo-${image//:/-}" +target="$cache/target-${image//:/-}" +mkdir -p "$here/dist" "$cargo_home" "$cache/rustup" "$target" # Never leave an older package around to be mistaken for this build. -rm -f "$here"/dist/*.deb +rm -f "$here"/dist/*.deb "$here"/dist/*.AppImage docker run --rm \ -v "$repo:$repo" \ - -v "$cache/cargo:/root/.cargo" \ + -v "$cargo_home:/root/.cargo" \ -v "$cache/rustup:/root/.rustup" \ - -v "$cache/target:$here/target-deb" \ + -v "$target:$here/target-deb" \ -e CARGO_TARGET_DIR="$here/target-deb" \ -e OWNER="$(id -u):$(id -g)" \ -e NODE_VERSION="$node_version" \ + -e NODE_ARCH="$node_arch" \ -e NODE_SHA256="$node_sha256" \ -e BUZZ_GPUI_RELEASE_VERSION \ -e BUZZ_GPUI_VOLTAGE \ + -e BUZZ_GPUI_APPIMAGE \ -w "$here" \ - ubuntu:26.04 bash -euo pipefail -c ' + "$image" bash -euo pipefail -c ' export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq --no-install-recommends \ @@ -43,8 +60,8 @@ docker run --rm \ # Node, for the plugins and to build them. bundle="$CARGO_TARGET_DIR/release/bundle" rm -rf "$bundle" && mkdir -p "$bundle/node/bin" "$bundle/plugins" - tarball="/tmp/node-v$NODE_VERSION-linux-x64.tar.xz" - curl -sSfL -o "$tarball" "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" + tarball="/tmp/node-v$NODE_VERSION-linux-$NODE_ARCH.tar.xz" + curl -sSfL -o "$tarball" "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$NODE_ARCH.tar.xz" echo "$NODE_SHA256 $tarball" | sha256sum -c --quiet mkdir -p /opt/node && tar -xJf "$tarball" -C /opt/node --strip-components=1 cp /opt/node/bin/node "$bundle/node/bin/node" @@ -81,6 +98,9 @@ docker run --rm \ "$bundle/node/bin/node" scripts/check-remote-provider.mjs "$extracted/usr/bin/buzz-backend-relay" rm -rf "$extracted" fi + if [ "${BUZZ_GPUI_APPIMAGE:-0}" = 1 ]; then + bash scripts/build-appimage.sh "${BUZZ_GPUI_RELEASE_VERSION:-$(dpkg-deb -f dist/*.deb Version)}" + fi chown -R "$OWNER" dist ' -ls -la "$here"/dist/*.deb +ls -la "$here"/dist/ diff --git a/apps/buzz-gpui/scripts/pin-nix-release.py b/apps/buzz-gpui/scripts/pin-nix-release.py new file mode 100644 index 00000000000..69911321416 --- /dev/null +++ b/apps/buzz-gpui/scripts/pin-nix-release.py @@ -0,0 +1,39 @@ +"""Pins a release's AppImages in nix/release.json, from which the flake +builds buzz-gpui-voltage-bin: the release's AppImage for each Linux system, +by URL and by the SRI hash that fetchurl checks. + +Usage: python3 scripts/pin-nix-release.py +where holds Buzz--linux-{x86_64,aarch64}.AppImage. +""" + +import base64 +import hashlib +import json +import pathlib +import sys +import urllib.parse + +# AppImage architecture -> Nix system. +SYSTEMS = {"x86_64": "x86_64-linux", "aarch64": "aarch64-linux"} +PIN = pathlib.Path(__file__).resolve().parent.parent / "nix" / "release.json" + + +def pin(version, repository, directory): + assets = {} + for arch, system in SYSTEMS.items(): + name = f"Buzz-{version}-linux-{arch}.AppImage" + with open(pathlib.Path(directory) / name, "rb") as image: + digest = hashlib.file_digest(image, "sha256").digest() + tag = urllib.parse.quote(f"v{version}", safe="") + assets[system] = { + "url": f"https://github.com/{repository}/releases/download/{tag}/" + + urllib.parse.quote(name, safe=""), + "hash": "sha256-" + base64.b64encode(digest).decode(), + } + return {"version": version, "assets": assets} + + +if __name__ == "__main__": + version, repository, directory = sys.argv[1:] + PIN.write_text(json.dumps(pin(version, repository, directory), indent=2) + "\n") + print(PIN.read_text(), end="") diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 56b5e9b61f8..7c4184de05c 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -152,25 +152,50 @@ The developer's Buzz identity must be a member of the `buzz.voltage.cloud` commu ### 1. Install the Buzz app (Voltage build) -Remote workspace agents need the `buzz-gpui-voltage` package. The flake builds it on Linux and macOS. You need only Nix: the flake supplies every other prerequisite. +Remote workspace agents need the Voltage build of the Buzz app. +Download it from the [latest release](https://github.com/voltagecloud/buzz/releases/latest). Every package in a release is a Voltage build. -To run it once: +| System | Download | Install | +|--------|----------|---------| +| macOS, Apple silicon | `Buzz--macos-arm64.dmg` | Open the image and drag Buzz to Applications. | +| macOS, Intel | `Buzz--macos-x86_64.dmg` | Open the image and drag Buzz to Applications. | +| Ubuntu 24.04 or Debian 13 and later, x86_64 | `buzz-gpui__amd64.deb` | `sudo apt install ./buzz-gpui__amd64.deb` | +| Ubuntu 24.04 or Debian 13 and later, ARM64 | `buzz-gpui__arm64.deb` | `sudo apt install ./buzz-gpui__arm64.deb` | +| Other Linux (glibc 2.39 or later) | `Buzz--linux-x86_64.AppImage` (ARM: `-aarch64`) | `chmod +x` the file, then run it. | +| NixOS, or Nix on any Linux | The flake's `buzz-gpui-voltage-bin` | See [Nix and NixOS](#nix-and-nixos). | + +The macOS build is not yet signed with a Developer ID. The first time, right-click Buzz in Applications and choose **Open**. If macOS still refuses, run `xattr -dr com.apple.quarantine /Applications/Buzz.app`. + +The app checks this repository's releases and offers each new version: the `.deb` installs it in place, and the other packages download it. + +#### Nix and NixOS + +On Linux (x86_64 or aarch64), the flake's `buzz-gpui-voltage-bin` package is the latest release, prebuilt: it runs the release AppImage in nixpkgs' `appimageTools` environment, so nothing compiles and NixOS needs no `nix-ld`. +After each release, the release workflow opens a pull request that pins the new AppImages. Once that is merged, `main` installs the new release. + +```sh +nix run 'github:voltagecloud/buzz?dir=apps/buzz-gpui#buzz-gpui-voltage-bin' +``` + +The `buzz-gpui-voltage` package builds the same app from source, on Linux and macOS. It needs only Nix, but it compiles the app and its dependencies. ```sh nix run 'github:voltagecloud/buzz/?dir=apps/buzz-gpui#buzz-gpui-voltage' ``` -To install it on NixOS or nix-darwin, add the flake input and install the package: +To install it on NixOS, add the flake input and install the package. On nix-darwin, install `buzz-gpui-voltage` instead. ```nix # flake.nix inputs -buzz-gpui.url = "github:voltagecloud/buzz/?dir=apps/buzz-gpui"; +buzz-gpui.url = "github:voltagecloud/buzz?dir=apps/buzz-gpui"; # configuration.nix or darwin-configuration.nix -environment.systemPackages = [ buzz-gpui.packages.${system}.buzz-gpui-voltage ]; +environment.systemPackages = [ buzz-gpui.packages.${system}.buzz-gpui-voltage-bin ]; ``` -On macOS, the Nix build runs from the terminal, not from an app bundle, so the app does not show system notifications. +A Nix install updates when you update the flake input (`nix flake update buzz-gpui`), not through the app's update notice. + +On macOS, the Nix build runs from the terminal, not from an app bundle, so the app does not show system notifications. Install the `.dmg` to get them. Sign in with your Buzz identity and join the `buzz.voltage.cloud` community. Install the GitHub CLI (`gh`) and sign in with `gh auth login`, so the app can add your signing key to GitHub.