diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 68e73d68..50776e9d 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -17,6 +17,10 @@ jobs: platform: darwin-arm64 - runner: ubuntu-24.04 platform: linux-x64 + ssh-platform: linux/amd64 + - runner: ubuntu-24.04-arm + platform: linux-arm64 + ssh-platform: linux/arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 @@ -38,6 +42,8 @@ jobs: if-no-files-found: error - name: Real SSH acceptance on an offline host if: runner.os == 'Linux' + env: + OXBIT_SSH_TEST_PLATFORM: ${{ matrix.ssh-platform }} run: bun run remote:test build: needs: remote-runtime @@ -51,6 +57,8 @@ jobs: platform: darwin-arm64 - runner: ubuntu-24.04 platform: linux-x64 + - runner: ubuntu-24.04-arm + platform: linux-arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/language-servers.yml b/.github/workflows/language-servers.yml index 6b10153a..bf1aa6aa 100644 --- a/.github/workflows/language-servers.yml +++ b/.github/workflows/language-servers.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - runner: [macos-26, ubuntu-24.04] + runner: [macos-26, ubuntu-24.04, ubuntu-24.04-arm] runs-on: ${{ matrix.runner }} env: OXBIT_LSP_REPORT: evidence/language-milestone1/real-servers.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3644debd..418b5d22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,8 @@ jobs: platform: darwin-arm64 - runner: ubuntu-24.04 platform: linux-x64 + - runner: ubuntu-24.04-arm + platform: linux-arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 @@ -103,6 +105,65 @@ jobs: name: remote-manifest path: release-files/remote-runtime-manifest.json if-no-files-found: error + # Linux arm64 is published when it builds; its failure does not block the release. + linux: + needs: remote-runtime + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux-x64 + - runner: ubuntu-24.04-arm + platform: linux-arm64 + runs-on: ${{ matrix.runner }} + continue-on-error: ${{ matrix.platform == 'linux-arm64' }} + timeout-minutes: 120 + env: + OXBIT_REMOTE_PAYLOADS: ${{ github.workspace }}/.desktop-cache/remote-payloads + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24.20.0" + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.2" + - name: Linux native dependencies (Ubuntu 24.04 minimum) + run: | + sudo apt-get update + sudo apt-get install -y build-essential python3 pkg-config libssl-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2t64 xdg-utils xvfb dbus-x11 + - run: rustup toolchain install 1.97.1 --profile minimal + - run: bun install --frozen-lockfile + - run: bun run build + - uses: actions/download-artifact@v4 + with: + pattern: remote-runtime-* + path: .desktop-cache/remote-payloads + - name: Build and sign the updater AppImage and .deb + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + OXBIT_UPDATER_PUBLIC_KEY: ${{ vars.OXBIT_UPDATER_PUBLIC_KEY }} + run: bun run desktop:release + - name: Installed package smoke checks + run: | + bundle=apps/desktop/src-tauri/target/release/bundle + sudo apt-get install -y ./"$bundle"/deb/*.deb + xvfb-run -a dbus-run-session -- bun run desktop:test:installed -- /usr/bin/oxbit-desktop + sudo apt-get remove -y "$(dpkg-deb --field "$bundle"/deb/*.deb Package)" + APPIMAGE_EXTRACT_AND_RUN=1 xvfb-run -a dbus-run-session -- node scripts/desktop/installed-smoke.mjs "$(realpath "$bundle"/appimage/*.AppImage)" + - name: Collect release files + run: | + bundle=apps/desktop/src-tauri/target/release/bundle + mkdir -p release-files + cp "$bundle"/deb/*.deb "$bundle"/appimage/*.AppImage "$bundle"/appimage/*.AppImage.sig release-files/ + ls -l release-files + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }} + path: release-files/ + if-no-files-found: error # The pinned manifest records the darwin payload after the macos job signs and repacks it. ios: needs: [remote-runtime, macos] @@ -181,7 +242,7 @@ jobs: path: release-files/ if-no-files-found: error publish: - needs: [macos, ios] + needs: [macos, ios, linux] if: github.ref_type == 'tag' && github.repository == 'yannelli/oxbit' runs-on: ubuntu-24.04 permissions: diff --git a/README.md b/README.md index 68791560..99e444c0 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,11 @@ allowed origins, and storage. ### Desktop The Tauri desktop app bundles Node, ripgrep, the terminal addon, and managed -language services. It targets Apple Silicon with macOS 26+ and Ubuntu 24.04+ x64. +language services. It targets Apple Silicon with macOS 26+ and Ubuntu 24.04+ x64 and arm64. Run `bun run desktop:dev` for development or `bun run desktop:build` for local installers. Use `oxbit --desktop [path]` to open an installed app. -**Connect over SSH…** opens a remote folder on Linux x64 or macOS Apple Silicon. +**Connect over SSH…** opens a remote folder on Linux x64, Linux arm64, or macOS Apple Silicon. Oxbit installs its headless runtime through SSH; the remote host does not need Node or npm for the bundled runtime. diff --git a/apps/desktop/src/ssh-dialog.tsx b/apps/desktop/src/ssh-dialog.tsx index f00e9248..adf35ad2 100644 --- a/apps/desktop/src/ssh-dialog.tsx +++ b/apps/desktop/src/ssh-dialog.tsx @@ -101,7 +101,7 @@ export function SshDialog({

Uses your SSH config and keys. Connect once in a terminal to verify a - new host. Supports Linux x64 and macOS Apple Silicon. + new host. Supports Linux x64 and arm64, and macOS Apple Silicon.

{error &&

{error}

}
diff --git a/apps/ios/src-tauri/src/remote_runtime.rs b/apps/ios/src-tauri/src/remote_runtime.rs index 7588a186..590bb3d5 100644 --- a/apps/ios/src-tauri/src/remote_runtime.rs +++ b/apps/ios/src-tauri/src/remote_runtime.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; include!(concat!(env!("OUT_DIR"), "/remote_runtime_manifest.rs")); -pub const PLATFORMS: [&str; 2] = ["darwin-arm64", "linux-x64"]; +pub const PLATFORMS: [&str; 3] = ["darwin-arm64", "linux-x64", "linux-arm64"]; const RELEASE_DOWNLOADS: &str = "https://github.com/yannelli/oxbit/releases/download"; #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -99,20 +99,21 @@ mod tests { const DARWIN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const LINUX: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + const ARM: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; fn manifest(version: &str, platforms: &str) -> String { format!(r#"{{"version":"{version}","platforms":{{{platforms}}}}}"#) } - fn both() -> String { + fn all() -> String { format!( - r#""darwin-arm64":{{"sha256":"{DARWIN}","size":52000000}},"linux-x64":{{"sha256":"{LINUX}","size":48000000}}"# + r#""darwin-arm64":{{"sha256":"{DARWIN}","size":52000000}},"linux-x64":{{"sha256":"{LINUX}","size":48000000}},"linux-arm64":{{"sha256":"{ARM}","size":47000000}}"# ) } #[test] fn parses_a_release_manifest_and_builds_download_urls() { - let parsed = parse(&manifest("0.4.0-alpha.1", &both()), "0.4.0-alpha.1").unwrap(); + let parsed = parse(&manifest("0.4.0-alpha.1", &all()), "0.4.0-alpha.1").unwrap(); assert_eq!( parsed.download("linux-x64").unwrap(), Download { @@ -125,12 +126,20 @@ mod tests { parsed.download("darwin-arm64").unwrap().url, "https://github.com/yannelli/oxbit/releases/download/v0.4.0-alpha.1/remote-runtime-darwin-arm64.tar.gz" ); + assert_eq!( + parsed.download("linux-arm64").unwrap(), + Download { + url: "https://github.com/yannelli/oxbit/releases/download/v0.4.0-alpha.1/remote-runtime-linux-arm64.tar.gz".into(), + sha256: ARM.into(), + size: 47_000_000, + } + ); assert!(parsed.download("darwin-x64").is_err()); } #[test] fn rejects_a_manifest_for_another_version() { - let error = parse(&manifest("0.3.3", &both()), "0.3.4").unwrap_err(); + let error = parse(&manifest("0.3.3", &all()), "0.3.4").unwrap_err(); assert!(error.contains("0.3.3"), "{error}"); } @@ -138,10 +147,11 @@ mod tests { fn rejects_missing_or_unknown_platforms() { let darwin_only = format!(r#""darwin-arm64":{{"sha256":"{DARWIN}","size":1}}"#); assert!(parse(&manifest("0.3.4", &darwin_only), "0.3.4").is_err()); - let with_intel = format!( - r#"{},"darwin-x64":{{"sha256":"{DARWIN}","size":1}}"#, - both() + let without_arm = format!( + r#""darwin-arm64":{{"sha256":"{DARWIN}","size":1}},"linux-x64":{{"sha256":"{LINUX}","size":1}}"# ); + assert!(parse(&manifest("0.3.4", &without_arm), "0.3.4").is_err()); + let with_intel = format!(r#"{},"darwin-x64":{{"sha256":"{DARWIN}","size":1}}"#, all()); assert!(parse(&manifest("0.3.4", &with_intel), "0.3.4").is_err()); } @@ -154,7 +164,7 @@ mod tests { (DARWIN, "0"), ] { let platforms = format!( - r#""darwin-arm64":{{"sha256":"{digest}","size":{size}}},"linux-x64":{{"sha256":"{LINUX}","size":1}}"# + r#""darwin-arm64":{{"sha256":"{digest}","size":{size}}},"linux-x64":{{"sha256":"{LINUX}","size":1}},"linux-arm64":{{"sha256":"{ARM}","size":1}}"# ); assert!( parse(&manifest("0.3.4", &platforms), "0.3.4").is_err(), @@ -163,7 +173,7 @@ mod tests { } let extra = format!( r#"{{"version":"0.3.4","schema":1,"platforms":{{{}}}}}"#, - both() + all() ); assert!(parse(&extra, "0.3.4").is_err()); } diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs index 2ae34f6b..f11d858d 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs @@ -1,7 +1,7 @@ //! Live remote runtime cases on the Docker sshd fixture: the wget install, a task service reached //! through `taskForward`, and the relaunch budget after repeated failures. use super::{ - live_runtime_tests::{live, payload, probe, source, start, Live, Seen}, + live_runtime_tests::{live, payload, probe, remote_platform, source, start, Live, Seen}, live_tests::{docker, exec}, runtime_process::Event, runtime_protocol::{Downloader, Install}, @@ -68,7 +68,12 @@ async fn live_runtime_wget_tasks_and_budget() { } = live().await; let (_, sha256) = payload(); let node = format!("/root/.oxbit/remote/runtimes/{sha256}/bin/node"); - let hosted = || source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + let hosted = || { + source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ) + }; exec(&container, "mv /usr/bin/curl /usr/bin/curl.off"); let seen: Seen = Arc::default(); diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs index 83cc5821..089c743f 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs @@ -21,15 +21,21 @@ use std::{ pub(super) type Seen = Arc>>; +/// The fixture's runtime platform; scripts/ios/ssh-live.mjs sets it from the container architecture. +pub(super) fn remote_platform() -> String { + std::env::var("OXBIT_SSH_TEST_REMOTE_PLATFORM").unwrap_or_else(|_| "linux-x64".into()) +} + pub(super) fn payload() -> (PathBuf, String) { let directory = PathBuf::from(env("OXBIT_SSH_TEST_PAYLOAD_DIR")); let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(directory.join("manifest.json")).unwrap()).unwrap(); - let sha256 = manifest["platforms"]["linux-x64"]["sha256"] + let platform = remote_platform(); + let sha256 = manifest["platforms"][&platform]["sha256"] .as_str() .unwrap() .to_string(); - (directory.join("linux-x64.tar.gz"), sha256) + (directory.join(format!("{platform}.tar.gz")), sha256) } /// `url` is what the host downloads; the device side copies the local archive. @@ -39,7 +45,7 @@ pub(super) fn source(url: String, scratch: PathBuf) -> Source { let download = Download { url, sha256, size }; Source { lookup: Arc::new(move |platform| { - assert_eq!(platform, "linux-x64"); + assert_eq!(platform, remote_platform()); Ok(download.clone()) }), fetch: Arc::new(move |download| { @@ -222,7 +228,10 @@ async fn live_remote_runtime() { exec(&container, "rm -rf /root/.oxbit/remote/runtimes"); let seen: Seen = Arc::default(); - let hosted = source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + let hosted = source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ); let runtime = start(&pool, "live", hosted.clone(), &seen).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!( diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs index 2f301e75..06e1455b 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs @@ -5,10 +5,11 @@ use crate::fs_core::{Error, Result}; pub fn platform(system: &str, machine: &str) -> Result<&'static str> { match (system.trim(), machine.trim()) { ("Linux", "x86_64") => Ok("linux-x64"), + ("Linux", "aarch64") => Ok("linux-arm64"), ("Darwin", "arm64") => Ok("darwin-arm64"), _ => Err(Error::new( "REMOTE_UNSUPPORTED", - "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon.", + "Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon.", )), } } diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs index 33eb9a39..fcb4de85 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs @@ -13,16 +13,17 @@ fn frame(value: Value) -> Vec { #[test] fn maps_uname_to_the_desktop_platform_names() { assert_eq!(platform("Linux", "x86_64").unwrap(), "linux-x64"); + assert_eq!(platform("Linux", "aarch64").unwrap(), "linux-arm64"); assert_eq!(platform("Darwin\n", "arm64\n").unwrap(), "darwin-arm64"); for (system, machine) in [ - ("Linux", "aarch64"), + ("Linux", "armv7l"), ("Darwin", "x86_64"), ("FreeBSD", "amd64"), ] { let error = platform(system, machine).unwrap_err(); assert_eq!( error.message, - "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon." + "Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon." ); } assert_eq!( @@ -33,6 +34,10 @@ fn maps_uname_to_the_desktop_platform_names() { parse_probe("Darwin\narm64\nwget\n").unwrap(), ("darwin-arm64", Some(Downloader::Wget)) ); + assert_eq!( + parse_probe("Linux\naarch64\ncurl\n").unwrap(), + ("linux-arm64", Some(Downloader::Curl)) + ); assert_eq!( parse_probe("Linux\nx86_64\nnone\n").unwrap(), ("linux-x64", None) diff --git a/apps/ios/src/ssh-connect.tsx b/apps/ios/src/ssh-connect.tsx index 47baa9ba..5cb3635e 100644 --- a/apps/ios/src/ssh-connect.tsx +++ b/apps/ios/src/ssh-connect.tsx @@ -106,7 +106,7 @@ export function SshConnect({ preset, mode = "files", progress, lastFolders = {}, event.preventDefault(); if (host && !busy) perform(() => connect(host)); }}> - {runtime &&

Oxbit installs its runtime on the server and runs terminals, tasks, and agents there. Linux x64 and macOS Apple Silicon servers are supported.

} + {runtime &&

Oxbit installs its runtime on the server and runs terminals, tasks, and agents there. Linux x64 and arm64 servers and macOS Apple Silicon servers are supported.

} {hosts && !hosts.length ?

Add a server in SSH Hosts and Keys first.

: <>