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 &&
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.
: <>
Server {
setHostId(event.target.value);
diff --git a/apps/runtime/src/managed/artifacts.lock.json b/apps/runtime/src/managed/artifacts.lock.json
index 4171d260..94b1834b 100644
--- a/apps/runtime/src/managed/artifacts.lock.json
+++ b/apps/runtime/src/managed/artifacts.lock.json
@@ -120,6 +120,66 @@
"entry": "laravel-lsp",
"license": "MIT",
"integrity": "sha256-z4Ftro0baMSh9yiORiVzKj+H3w3M31oGfn4xW5ZDyII="
+ },
+ {
+ "id": "marksman",
+ "version": "2026-02-08",
+ "platform": "linux-arm64",
+ "url": "https://github.com/artempyanykh/marksman/releases/download/2026-02-08/marksman-linux-arm64",
+ "format": "file",
+ "entry": "marksman",
+ "license": "MIT",
+ "integrity": "sha256-244SRSf3+ASOPmyRghucUu8XPZLAHkfSIb8TN6/ZYvs="
+ },
+ {
+ "id": "taplo",
+ "version": "0.10.0",
+ "platform": "linux-arm64",
+ "url": "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-aarch64.gz",
+ "format": "gzip",
+ "entry": "taplo",
+ "license": "MIT",
+ "integrity": "sha256-AzaB0B7sg3bD/Tj6NwPHkxb14UuwE9hZlDtgoHvM3MM="
+ },
+ {
+ "id": "shellcheck",
+ "version": "0.11.0",
+ "platform": "linux-arm64",
+ "url": "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.gz",
+ "format": "tar",
+ "entry": "shellcheck-v0.11.0/shellcheck",
+ "license": "GPL-3.0-or-later",
+ "integrity": "sha256-aKgTMZelC+uIA/jUL5kI0a8cVUDUuwX9/KjB+kfezvw="
+ },
+ {
+ "id": "jre",
+ "version": "21.0.12.1+1",
+ "platform": "linux-arm64",
+ "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_aarch64_linux_hotspot_21.0.12.1_1.tar.gz",
+ "format": "tar",
+ "entry": "jdk-21.0.12.1+1-jre/bin/java",
+ "license": "GPL-2.0-with-classpath-exception",
+ "integrity": "sha256-FL4fNevb0fbo1X65EaP/t01tmqJVq8Xa8rEwIALPLPI="
+ },
+ {
+ "id": "lemminx",
+ "version": "0.31.2",
+ "platform": "linux-arm64",
+ "url": "https://repo.eclipse.org/content/repositories/lemminx-releases/org/eclipse/lemminx/org.eclipse.lemminx/0.31.2/org.eclipse.lemminx-0.31.2-uber.jar",
+ "format": "file",
+ "entry": "lemminx.jar",
+ "license": "EPL-2.0",
+ "integrity": "sha256-9P3hZOeFxjXl+GNh2/S5k7/ixfg/21KJHQWLffybz8g="
+ },
+ {
+ "id": "laravel",
+ "version": "0.0.31",
+ "platform": "linux-arm64",
+ "url": "https://github.com/laravel/lsp/releases/download/v0.0.31/server-v0.0.31-arm64-linux",
+ "format": "file",
+ "entry": "laravel-lsp",
+ "license": "MIT",
+ "integrity": "sha256-RfTFNT465eyrEYgqQhP88PBuNzUBx+HdsPF5toqPsnQ="
}
]
}
diff --git a/apps/runtime/src/managed/install.ts b/apps/runtime/src/managed/install.ts
index c6cb3449..c6ab92e7 100644
--- a/apps/runtime/src/managed/install.ts
+++ b/apps/runtime/src/managed/install.ts
@@ -7,7 +7,8 @@ import { x as untar } from "tar";
import npmLock from "./npm/package-lock.json" with { type: "json" };
import nativeLock from "./artifacts.lock.json" with { type: "json" };
-export type ManagedPlatform = "darwin-arm64" | "linux-x64";
+export const managedPlatforms = ["darwin-arm64", "linux-x64", "linux-arm64"] as const;
+export type ManagedPlatform = (typeof managedPlatforms)[number];
type Package = { version?: string; resolved?: string; integrity?: string; dependencies?: Record; optionalDependencies?: Record };
const packages = npmLock.packages as Record;
export function fingerprint(value: unknown): string {
@@ -85,7 +86,7 @@ export class ManagedInstaller {
} finally { await fs.rm(archive, { force: true }); }
}
private async install(id: string, digest: string, build: (stage: string) => Promise, signal?: AbortSignal) {
- if (!["darwin-arm64", "linux-x64"].includes(this.platform)) throw new Error(`Managed language servers are unavailable on ${this.platform}`);
+ if (!(managedPlatforms as readonly string[]).includes(this.platform)) throw new Error(`Managed language servers are unavailable on ${this.platform}`);
const base = path.join(this.cache, `${id}-${this.platform}`), target = path.join(base, digest);
const complete = async () => { try { return (await fs.readFile(path.join(target, ".complete"), "utf8")) === digest; } catch { return false; } };
if (await complete()) return target;
diff --git a/apps/runtime/src/ssh.ts b/apps/runtime/src/ssh.ts
index 549a9e5d..36b4ce71 100644
--- a/apps/runtime/src/ssh.ts
+++ b/apps/runtime/src/ssh.ts
@@ -230,14 +230,15 @@ export async function connectSsh(options: SshOptions) {
const lines = platform.split(/\r?\n/);
const pair = lines.slice(-2).join("/");
const name = (
- { "Linux/x86_64": "linux-x64", "Darwin/arm64": "darwin-arm64" } as Record<
- string,
- string
- >
+ {
+ "Linux/x86_64": "linux-x64",
+ "Linux/aarch64": "linux-arm64",
+ "Darwin/arm64": "darwin-arm64",
+ } as Record
)[pair];
if (!name)
throw new Error(
- "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon.",
+ "Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon.",
);
const manifest = JSON.parse(
await fs.readFile(
diff --git a/apps/runtime/tests/lsp-manager.test.ts b/apps/runtime/tests/lsp-manager.test.ts
index db055f9d..864f7cca 100644
--- a/apps/runtime/tests/lsp-manager.test.ts
+++ b/apps/runtime/tests/lsp-manager.test.ts
@@ -4,7 +4,8 @@ import path from "node:path";
import os from "node:os";
import { WorkspaceFiles } from "../src/filesystem.js";
import { LanguageServerManager } from "../src/lsp-manager.js";
-import { fingerprint, lockedPackages, ManagedInstaller, verifyIntegrity } from "../src/managed/install.js";
+import { fingerprint, lockedPackages, ManagedInstaller, managedPlatforms, verifyIntegrity } from "../src/managed/install.js";
+import nativeLock from "../src/managed/artifacts.lock.json" with { type: "json" };
import { createHash } from "node:crypto";
const cleanup: (() => Promise)[] = [];
@@ -98,6 +99,12 @@ describe("managed installation locks", () => {
await expect(installer.native("marksman", controller.signal)).rejects.toThrow();
await expect(new ManagedInstaller(root, "win32-x64" as any).native("marksman")).rejects.toThrow("No pinned");
});
+
+ it("pins every native server for every managed platform", () => {
+ const ids = [...new Set(nativeLock.artifacts.map(artifact => artifact.id))].sort();
+ for (const platform of managedPlatforms)
+ expect(nativeLock.artifacts.filter(artifact => artifact.platform === platform).map(artifact => artifact.id).sort(), platform).toEqual(ids);
+ });
});
it("installs atomically once across concurrent callers and retains a working digest on failure", async () => {
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 720afe19..3d288438 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -1,12 +1,13 @@
# Documentation index
-Created: 2026-10-02. Last updated: 2026-10-08.
+Created: 2026-10-02. Last updated: 2026-10-09.
| Guide | Use when |
| --- | --- |
| [Signed releases](release.md) | Cutting a release; signing and publishing; versioned TestFlight notes, `Internal Testing` assignment, `Public Beta` promotion, and export compliance; secrets and renewal; Apple TestFlight API references for distribution changes. |
| [npm packages](npm.md) | Publishing `@oxbit/sdk` and `@oxbit/cli`; staging layout; install scripts; trusted publishing setup and token removal; npm trusted publishing and provenance references. |
-| [Remote workspaces over SSH](remote-ssh.md) | Building SSH runtime payloads; the release's `remote-runtime-*` assets and the manifest pinned into the iOS build. |
+| [Remote workspaces over SSH](remote-ssh.md) | Building SSH runtime payloads for `darwin-arm64`, `linux-x64`, and `linux-arm64`; the release's `remote-runtime-*` assets and the manifest pinned into the iOS build; running the SSH live harnesses against an arm64 container; Node, ripgrep, node-pty, GitHub arm runner, and Docker multi-platform references. |
+| [Oxbit desktop](desktop.md) | Building, testing, and packaging the Tauri app for macOS arm64 and Ubuntu 24.04 x64/arm64; Linux bundle helper pins; updater manifest platforms; release checks. |
| [iOS setup](ios.md) | Building the iOS app or connecting to a computer runtime. |
| [iOS release artwork](ios-release-assets.md) | Rendering app icon exports, native App Store screenshots, promo banners, and listing drafts; includes Apple design, asset specifications, and app privacy references. |
| [Withdrawn v0.3.1 audit](release-audit-0.3.1.md) | Comparing alpha.5 with the withdrawn release, confirmed corrections, and unresolved CI failures. |
diff --git a/docs/desktop.md b/docs/desktop.md
index 7c7273d9..4d4d1219 100644
--- a/docs/desktop.md
+++ b/docs/desktop.md
@@ -1,6 +1,8 @@
# Oxbit desktop
-Oxbit uses Tauri 2 and the shared React workbench. The desktop targets are **Apple Silicon macOS 26+** and **Ubuntu 24.04+ x64**. Ubuntu 24.04 is the build baseline and minimum supported Linux distribution. Windows, Intel Macs, Linux arm64, and app stores are outside this implementation.
+Created: 2026-09-07. Last updated: 2026-10-09.
+
+Oxbit uses Tauri 2 and the shared React workbench. The desktop targets are **Apple Silicon macOS 26+** and **Ubuntu 24.04+ x64 and arm64**. Ubuntu 24.04 is the build baseline and minimum supported Linux distribution. Windows, Intel Macs, and app stores are outside this implementation.
The application identifier is `com.yannelli.oxbit`. The iPhone and iPad app in `apps/ios` shares it; see [iOS](ios.md). `package.json` at the repository root supplies the application and release version.
@@ -41,7 +43,7 @@ An outer X11 display remains available for clipboard interoperability.
## Runtime packaging
-`scripts/desktop/binaries.json` pins Node, ripgrep, and Linux packaging helpers with SHA-256 checksums.
+`scripts/desktop/binaries.json` pins Node, ripgrep, and Linux packaging helpers with SHA-256 checksums. `linuxBundleTools` lists the helpers per Node `process.arch` (`x64`, `arm64`), using the file names Tauri's bundler looks for (`AppRun-`, `linuxdeploy-.AppImage`, `linuxdeploy-plugin-appimage.AppImage`). The `linuxdeploy` and `linuxdeploy-plugin-appimage` downloads come from the mutable `linuxdeploy` and `continuous` release tags, so upstream rebuilds change their checksums; the pins were refreshed on 2026-10-09.
`bundle-tools.mjs` seeds and verifies Tauri's helper cache before packaging;
plugin scripts use immutable upstream commits. Changed upstream binary assets
fail the checksum gate and require an explicit reviewed pin update. `desktop:prepare` stages ordinary files under `apps/desktop/src-tauri/resources/runtime`, including the production dependency tree, TypeScript, the language server, `node-pty`, spawn helpers, dependency notices, and a package inventory. There are no links into the package manager store. Downloads cache in `.desktop-cache`; both cache and generated resources are ignored by Git.
@@ -91,7 +93,7 @@ for signing configuration, local Keychain setup, final-DMG submission, and
local build checks. `scripts/desktop/notarize.mjs` accepts
`APPLE_KEYCHAIN_PROFILE` and explicit `--app` / `--dmg` paths for local builds.
-`.github/workflows/desktop.yml` builds macOS arm64 and Ubuntu 24.04 x64 for pull requests without signing secrets; macOS uses ad hoc signing. Local macOS builds also use ad hoc signing unless `APPLE_SIGNING_IDENTITY` selects a certificate. Matching version tags (`v`) run `.github/workflows/release.yml`, which signs, notarizes, and publishes the macOS release; see [signed releases](release.md).
+`.github/workflows/desktop.yml` builds macOS arm64 and Ubuntu 24.04 x64 and arm64 (`ubuntu-24.04-arm`) for pull requests without signing secrets; macOS uses ad hoc signing. Local macOS builds also use ad hoc signing unless `APPLE_SIGNING_IDENTITY` selects a certificate. Matching version tags (`v`) run `.github/workflows/release.yml`, which signs, notarizes, and publishes the macOS release and publishes the Linux `.deb` and updater-signed AppImage for x64 and, when its job succeeds, arm64; see [signed releases](release.md).
The release workflow reads these GitHub Actions secrets:
@@ -110,7 +112,7 @@ Set the repository **variable** `OXBIT_UPDATER_PUBLIC_KEY` to the matching publi
Nested Mach-O executables/libraries are signed first. Bundled Node receives the JIT, unsigned executable memory, and library-loading entitlements it needs. The application uses the hardened runtime without granting Node's exceptions to the main executable. Tauri signs/notarizes the app; the separate `notarize.mjs` step signs, submits, staples, validates, and assesses the final DMG. Validate the downloaded artifact on a separate Mac before publication.
-Signed macOS `.app.tar.gz` and Linux `.AppImage` update artifacts have separate `.sig` files. `publish-release.mjs` uploads the artifacts to a draft first, then `manifest.mjs` verifies their presence and writes the GitHub-hosted `latest.json` (Linux is included when an AppImage is present), and the script publishes the draft. It refuses to replace assets on a published release. The app checks on startup and on request; installation requires acceptance. Invalid signatures or download failures leave the installed app untouched. Restart runs the same multi-window close flow. `.deb` installations link to package downloads instead of replacing themselves with an AppImage. Unsigned development builds have no working production updater configuration.
+Signed macOS `.app.tar.gz` and Linux `.AppImage` update artifacts have separate `.sig` files. `publish-release.mjs` uploads the artifacts to a draft first, then `manifest.mjs` verifies their presence and writes the GitHub-hosted `latest.json` with `darwin-aarch64`, `linux-x86_64` (`*_amd64.AppImage`), and `linux-aarch64` (`*_aarch64.AppImage`, when present), and the script publishes the draft. It refuses to replace assets on a published release. The app checks on startup and on request; installation requires acceptance. Invalid signatures or download failures leave the installed app untouched. Restart runs the same multi-window close flow. `.deb` installations link to package downloads instead of replacing themselves with an AppImage. Unsigned development builds have no working production updater configuration.
### Local macOS builds
@@ -136,6 +138,6 @@ CI=1 bun run desktop:build
## Release checks
-Before publishing, check the installed artifacts, including a clean Ubuntu 24.04 x64 installation under X11 and Wayland, and downloaded-DMG/Gatekeeper checks on macOS 26+. Test without system Node/Bun, missing Git/gh, existing gh authentication, read-only application resources, spaces/Unicode paths, native dialogs/clipboard, accessibility, conflicts, extensions, and collaboration. The browser collaboration regression suite does not establish native desktop sharing acceptance.
+Before publishing, check the installed artifacts, including clean Ubuntu 24.04 x64 and arm64 installations under X11 and Wayland, and downloaded-DMG/Gatekeeper checks on macOS 26+. Test without system Node/Bun, missing Git/gh, existing gh authentication, read-only application resources, spaces/Unicode paths, native dialogs/clipboard, accessibility, conflicts, extensions, and collaboration. The browser collaboration regression suite does not establish native desktop sharing acceptance.
Finally install a signed previous version, open multiple projects with unsaved files and active tools, and accept a signed version-to-version update. Exercise Cancel, Save All, Discard, offline checks, invalid signatures, interrupted downloads, and an unwritable installation. Verify restored drafts/layout and no replayed tools after restart. Record the platform, version, and results with the release.
diff --git a/docs/ios-ssh.md b/docs/ios-ssh.md
index b1541acb..8aae1ea4 100644
--- a/docs/ios-ssh.md
+++ b/docs/ios-ssh.md
@@ -1,6 +1,6 @@
# iOS SSH and SFTP workspaces
-Created: 2026-10-08. Last updated: 2026-10-08.
+Created: 2026-10-08. Last updated: 2026-10-09.
The iOS app opens folders on a server over SFTP, starts an Oxbit runtime on a server, and uses SSH remotes for Git in device folders. Keys, saved hosts, host-key trust, the connection pool, and transfers live in the iOS crate (`apps/ios/src-tauri/src/ssh/`). Private keys and saved passwords live in the Keychain through the `oxbit-files` plugin.
@@ -109,7 +109,7 @@ Host keys share `known_hosts.json` and its refusal rules with SFTP workspaces, s
Start Oxbit on This Server runs the desktop's headless runtime (`desktop.js`) on a saved host and connects the iOS runtime client to it, so terminals, tasks, and agents run on the server. The Rust side mirrors `apps/runtime/src/ssh.ts` and `ssh-target.ts` and lives in `apps/ios/src-tauri/src/ssh/runtime_*.rs`. The remote layout matches the desktop, so a host shared with the desktop reuses its install under `~/.oxbit/remote/runtimes/`.
-- Platform: `uname -s; uname -m` maps to `linux-x64` or `darwin-arm64`. Other hosts fail with the desktop's message.
+- Platform: `uname -s; uname -m` maps to `linux-x64`, `linux-arm64`, or `darwin-arm64`. Other hosts fail with the desktop's message.
- Payload: `runtime_install::pinned` is the one manifest lookup. It calls `remote_runtime::pinned_download`, which reads `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST` at build time. A build without a manifest reports that it has no remote runtime.
- Install order: a `.complete` marker skips the install. Otherwise the host runs `curl -fsSL` (with a 15 s connect timeout and a 30 s stall limit) or `wget -qO-` piped into the desktop's install script, which checks the pinned SHA-256 before it writes `.complete`. If the host has neither tool or the download fails, the `oxbit-files` plugin downloads the archive with `URLSession` (`RuntimeDownloadStore.swift`, HTTPS only) and keeps it in Caches only when its size and SHA-256 match. Rust checks both again, and the archive streams into the same script over the exec channel. A unit test compares the script byte for byte with `ssh-target.ts`.
- Launch: `bin/node desktop.js` runs with `NODE_OPTIONS`, `NODE_PATH`, and `OXBIT_LSP_COMMAND` unset. The launch frame carries `remoteRuntime: true`, the root, the workspace key, and a random token. The runtime then accepts that token as the owner, so `/api/pair` and its rate limit are not used. Stdout frames follow version 1 with a 64 KiB line cap: `ready`, `rotated`, `taskForward`, `error`, and `progress`. A heartbeat goes out every 10 s against the runtime's 75 s lease.
diff --git a/docs/language-support.md b/docs/language-support.md
index 718b35e7..310e1021 100644
--- a/docs/language-support.md
+++ b/docs/language-support.md
@@ -79,7 +79,9 @@ node scripts/language/build-native.mjs
bunx wdio run tests/desktop/language.conf.mjs
```
-`.github/workflows/language-servers.yml` provides a macOS/Ubuntu server matrix, clean and cached startup, real intelligence checks and reproducible bundles.
+`.github/workflows/language-servers.yml` provides a macOS arm64, Ubuntu x64, and Ubuntu arm64 server matrix, clean and cached startup, real intelligence checks and reproducible bundles.
+
+Managed servers install on `darwin-arm64`, `linux-x64`, and `linux-arm64` (`managedPlatforms` in `apps/runtime/src/managed/install.ts`). `artifacts.lock.json` pins marksman, taplo, ShellCheck, the Temurin 21 JRE, LemMinX, and the Laravel LSP for each of them from the upstream release assets. The npm-based servers install from `npm/package-lock.json`, which has no platform-specific packages. Other platforms report `Managed language servers are unavailable on `.
## Capability matrix
diff --git a/docs/project-intelligence.md b/docs/project-intelligence.md
index 93ee8637..62f514ab 100644
--- a/docs/project-intelligence.md
+++ b/docs/project-intelligence.md
@@ -69,7 +69,7 @@ The syntax fallback and static project index do not implement full MDX compilati
## Laravel and Blade
-The official [`laravel/lsp` 0.0.31](https://github.com/laravel/lsp/releases/tag/v0.0.31) standalone binaries are pinned with SHA-256 integrity for Apple Silicon macOS and Linux x64. The installer does not require a global Composer installation. PHP files inside an `artisan` root use Laravel alongside Intelephense; `.blade.php` files have the `blade` language ID and use Laravel with PHP/HTML syntax fallback. Plain PHP projects continue using Intelephense alone. Nested `composer.json` files do not displace the Laravel application's `artisan` root.
+The official [`laravel/lsp` 0.0.31](https://github.com/laravel/lsp/releases/tag/v0.0.31) standalone binaries are pinned with SHA-256 integrity for Apple Silicon macOS, Linux x64, and Linux arm64. The installer does not require a global Composer installation. PHP files inside an `artisan` root use Laravel alongside Intelephense; `.blade.php` files have the `blade` language ID and use Laravel with PHP/HTML syntax fallback. Plain PHP projects continue using Intelephense alone. Nested `composer.json` files do not displace the Laravel application's `artisan` root.
Laravel's server inspects a running project through its PHP environment. The application's dependencies and supported PHP environment must be available. It runs only through the existing trusted-runtime LSP boundary. The upstream auto-detection can be overridden using `languageServers.laravel.initializationOptions.phpEnvironment` or `phpCommand`, for example:
diff --git a/docs/release.md b/docs/release.md
index 63159c6b..e3970ff6 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -1,16 +1,17 @@
# Signed releases
-Created: 2026-10-02. Last updated: 2026-10-08.
+Created: 2026-10-02. Last updated: 2026-10-09.
`.github/workflows/release.yml` builds, signs, and publishes a release when a
`v` tag is pushed. The tag must match the root `package.json` version.
| Job | Output |
| --- | --- |
-| `remote-runtime` | SSH runtime payloads for `darwin-arm64` and `linux-x64` |
+| `remote-runtime` | SSH runtime payloads for `darwin-arm64` (`macos-26`), `linux-x64` (`ubuntu-24.04`), and `linux-arm64` (`ubuntu-24.04-arm`) |
| `macos` | Developer ID signed, notarized, and stapled app and DMG; signed updater archive; web, runtime, and extension tarballs; the bundled SSH runtime payloads and `remote-runtime-manifest.json` |
+| `linux` | Linux x64 (`ubuntu-24.04`) and arm64 (`ubuntu-24.04-arm`) `.deb` and AppImage with the AppImage updater `.sig`, after installed-package smoke checks. The arm64 leg has `continue-on-error`, so a failed arm64 build publishes without arm64 Linux files |
| `ios` | App Store IPA with the pinned `remote-runtime-manifest.json`, validated and uploaded to TestFlight with tester notes and assignment to the internal `Internal Testing` group; starts after `macos` |
-| `publish` | GitHub release with the files above, `latest.json`, and `SHA256SUMS` |
+| `publish` | GitHub release with the files above, `latest.json`, and `SHA256SUMS`; waits for `macos`, `ios`, and `linux` and requires the macOS, iOS, Linux x64, and three remote runtime files |
The SSH runtime release assets and the iOS manifest pin are described in [Remote workspaces over SSH](remote-ssh.md#release-assets).
@@ -139,6 +140,10 @@ Repository variable `OXBIT_UPDATER_PUBLIC_KEY` holds the updater public key.
## References
+- [GitHub-hosted runners and image contents](https://github.com/actions/runner-images), including the [Ubuntu 24.04 Arm64 image](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Arm64-Readme.md) behind `ubuntu-24.04-arm`
+- [Tauri updater](https://v2.tauri.app/plugin/updater/) `latest.json` platform keys (`linux-x86_64`, `linux-aarch64`, `darwin-aarch64`)
+- [Node.js v24.20.0 distribution](https://nodejs.org/dist/v24.20.0/) and [node-pty](https://github.com/microsoft/node-pty) for the arm64 runtime pins and the PTY build
+
- [Tauri iOS code signing](https://v2.tauri.app/distribute/sign/ios/)
- [Tauri macOS signing](https://v2.tauri.app/distribute/sign/macos/)
- [App Store Connect API: certificates](https://developer.apple.com/documentation/appstoreconnectapi/certificates)
diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md
index 114c9f17..e83a618a 100644
--- a/docs/remote-ssh.md
+++ b/docs/remote-ssh.md
@@ -1,12 +1,12 @@
# Remote workspaces over SSH
-Created: 2026-09-08. Last updated: 2026-10-08.
+Created: 2026-09-08. Last updated: 2026-10-09.
Choose **Connect over SSH…** in Oxbit's project bar, File menu, or command palette. Enter an SSH config alias or `user@host`, a remote folder or file such as `~/projects/app`, and an optional port. Remote projects appear in the project switcher and recent projects with an **SSH** label and restore when the app starts.
Oxbit uses the system OpenSSH client and your existing `~/.ssh/config`, identity files, SSH agent, and jump-host configuration. Connect to a new host once in a terminal to verify its host key and confirm key authentication. Password prompts and host-key acceptance are not embedded in the editor. The connector requires a known host and noninteractive key authentication; it never disables host-key checking or forwards your authentication agent.
-The remote machine needs SSH access, a POSIX shell, `tar`, and `sha256sum` or `shasum`. Supported runtime targets are **Linux x64 with glibc** and **macOS Apple Silicon**. Linux payloads built by the macOS cross-build use Debian bookworm; native release payloads use Ubuntu 24.04. Linux systems must satisfy the native payload's libc requirements. Alpine/musl, Linux ARM, Windows SSH hosts, and macOS Intel are not supported.
+The remote machine needs SSH access, a POSIX shell, `tar`, and `sha256sum` or `shasum`. Supported runtime targets are **Linux x64 and arm64 (aarch64) with glibc** and **macOS Apple Silicon**. Linux payloads built on macOS compile the PTY in a Debian bookworm container; release payloads compile it on Ubuntu 24.04 runners of the target architecture. Linux systems must satisfy the native payload's libc requirements. Alpine/musl, 32-bit ARM, Windows SSH hosts, and macOS Intel are not supported.
No Node, npm, package manager, compiler, elevated privileges, or outbound internet connection is needed on the remote host. Oxbit copies a matching headless runtime, Node 24, ripgrep, TypeScript language server, and the native PTY module over SSH. Git and project-specific tools use the remote account's installed tools. Additional managed language servers retain their existing download requirements when first activated.
@@ -26,23 +26,34 @@ To remove cached runtime versions, close remote projects first, then remove the
## Building and checking
-Run `bun run remote:prepare` to build and include both payloads in `apps/desktop/src-tauri/resources/runtime/remote`. This is also part of desktop preparation. Downloads are pinned and verified using `scripts/desktop/binaries.json`. macOS development builds use Docker to compile the Linux PTY against the pinned Node 24 build image. Docker's CLI and credential helper must be on PATH. Linux x64 builders use the installed native PTY build; build with Node 24 and enabled dependency lifecycle scripts.
+Run `bun run remote:prepare` to build and include the `darwin-arm64`, `linux-x64`, and `linux-arm64` payloads in `apps/desktop/src-tauri/resources/runtime/remote`. This is also part of desktop preparation. Downloads are pinned and verified using `scripts/desktop/binaries.json`. node-pty 1.1.0 ships no Linux prebuilds, so each Linux payload carries a `pty.node` compiled with node-gyp for its architecture. A Linux builder uses its own installed native PTY build for its architecture; build with Node 24 and enabled dependency lifecycle scripts. Other Linux targets compile in Docker against the pinned `node:24.20.0-bookworm` image (`linux/amd64` runs emulated on Apple Silicon, `linux/arm64` runs natively). `scripts/remote/prepare.mjs` pins each platform's manifest digest: Docker's classic image store keeps one platform per multi-platform index digest, so pulling both platforms by the index digest fails with "cannot overwrite digest". Docker's CLI and credential helper must be on PATH.
-For a native single-target artifact, set `OXBIT_REMOTE_TARGETS=darwin-arm64` or `OXBIT_REMOTE_TARGETS=linux-x64`. The desktop CI workflow builds these on their native runners, then assembles both artifacts into each desktop application using `OXBIT_REMOTE_PAYLOADS`. This avoids a Docker dependency on macOS CI runners. Missing platform payloads fail explicitly at connection time.
+For a native single-target artifact, set `OXBIT_REMOTE_TARGETS` to `darwin-arm64`, `linux-x64`, or `linux-arm64`. The desktop and release workflows build these on `macos-26`, `ubuntu-24.04`, and `ubuntu-24.04-arm`, then assemble all three artifacts into each desktop application using `OXBIT_REMOTE_PAYLOADS`. This avoids a Docker dependency on macOS CI runners. Missing platform payloads fail explicitly at connection time.
## Release assets
Each tagged release attaches the payloads the macOS app bundles, copied after `scripts/desktop/sign-resources.mjs` signs and repacks the darwin archive:
-- `remote-runtime-darwin-arm64.tar.gz` and `remote-runtime-linux-x64.tar.gz`
+- `remote-runtime-darwin-arm64.tar.gz`, `remote-runtime-linux-x64.tar.gz`, and `remote-runtime-linux-arm64.tar.gz`
- `remote-runtime-manifest.json`: `{"version": "", "platforms": {"": {"sha256": "", "size": }}}`
-`node scripts/remote/release-manifest.mjs ...` writes these files. Each payload directory holds `manifest.json` and `.tar.gz`, as produced by `bun run remote:prepare`. The script checks each archive against its `manifest.json` digest and requires both platforms.
+`node scripts/remote/release-manifest.mjs ...` writes these files. Each payload directory holds `manifest.json` and `.tar.gz`, as produced by `bun run remote:prepare`. The script checks each archive against its `manifest.json` digest and requires all three platforms. `scripts/desktop/publish-release.mjs` refuses to publish without each archive and the manifest.
-The iOS release build embeds the combined manifest. The `ios` job in `release.yml` waits for the `macos` job, downloads the `remote-manifest` artifact, and sets `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST` to its absolute path. `tauri ios build` runs cargo from the Xcode script phase and with a filtered environment that keeps `TAURI_` variables and drops `OXBIT_` ones. `apps/ios/src-tauri/build.rs` turns that file into a constant; without the variable, local and simulator builds compile with no manifest. `remote_runtime::pinned_manifest()` and `remote_runtime::pinned_download(platform)` in `apps/ios/src-tauri/src/remote_runtime.rs` reject a manifest with unknown fields, a missing or unknown platform, a digest other than 64 lowercase hex characters, an empty payload, or a version other than the app's Cargo version. Download URLs follow `https://github.com/yannelli/oxbit/releases/download/v/remote-runtime-.tar.gz`. Desktop and iOS install the same digest into `~/.oxbit/remote/runtimes/` on a host, so they share one installed runtime.
+The iOS release build embeds the combined manifest. The `ios` job in `release.yml` waits for the `macos` job, downloads the `remote-manifest` artifact, and sets `TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST` to its absolute path. `tauri ios build` runs cargo from the Xcode script phase and with a filtered environment that keeps `TAURI_` variables and drops `OXBIT_` ones. `apps/ios/src-tauri/build.rs` turns that file into a constant; without the variable, local and simulator builds compile with no manifest. `remote_runtime::pinned_manifest()` and `remote_runtime::pinned_download(platform)` in `apps/ios/src-tauri/src/remote_runtime.rs` require exactly the three platforms and reject a manifest with unknown fields, a missing or unknown platform, a digest other than 64 lowercase hex characters, an empty payload, or a version other than the app's Cargo version. Download URLs follow `https://github.com/yannelli/oxbit/releases/download/v/remote-runtime-.tar.gz`. Desktop and iOS install the same digest into `~/.oxbit/remote/runtimes/` on a host, so they share one installed runtime.
- `bun run test` includes SSH target validation, shell quoting, checksum failure, and installation tests.
-- `bun run remote:test` builds an ephemeral Linux SSH container, creates temporary keys and a private known-hosts file, removes its outbound route, and exercises cold installation, remote file editing, conflicts, search, Git, real PTYs, TypeScript completion, token rotation, cached reconnection, shutdown, and transport loss. It cleans up the container and temporary keys.
+- `bun run remote:test` builds an ephemeral Linux SSH container, creates temporary keys and a private known-hosts file, removes its outbound route, and exercises cold installation, remote file editing, conflicts, search, Git, real PTYs, TypeScript completion, token rotation, cached reconnection, shutdown, and transport loss. It cleans up the container and temporary keys. The container is `linux/amd64` by default; `OXBIT_SSH_TEST_PLATFORM=linux/arm64 bun run remote:test` runs it as arm64, so the connector installs the `linux-arm64` payload. CI runs it on both Linux runners.
+- `bun run ios:ssh-live` runs the iOS crate's ignored live tests against the same fixture. `OXBIT_SSH_TEST_PLATFORM=linux/arm64` selects the arm64 container and serves `linux-arm64.tar.gz` to the host.
- After `bun run desktop:test:build`, `bun run remote:test:native` checks the connection dialog, keyboard dismissal, input validation, and URI submission in the native WebView. Its submission is captured by the UI test; the separate SSH smoke test covers real transport.
OpenSSH's [connection sharing and local forwarding](https://man.openbsd.org/ssh.1) and [SSH configuration](https://man.openbsd.org/ssh_config.5) define the underlying transport behavior.
+
+## References
+
+Use these when changing payload targets, pins, or runners:
+
+- [Node.js v24.20.0 downloads and SHASUMS256.txt](https://nodejs.org/dist/v24.20.0/): source of the `node-v24.20.0-linux-arm64.tar.gz` pin.
+- [ripgrep 15.1.0 release](https://github.com/BurntSushi/ripgrep/releases/tag/15.1.0): `aarch64-unknown-linux-gnu` is the arm64 Linux build; there is no aarch64 musl asset.
+- [node-pty](https://github.com/microsoft/node-pty): 1.1.0 prebuilds cover macOS and Windows only; Linux compiles with node-gyp.
+- [GitHub-hosted runners](https://github.com/actions/runner-images) and the [Ubuntu 24.04 Arm64 image contents](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Arm64-Readme.md): the `ubuntu-24.04-arm` label; its image includes Docker, GCC, make, Python 3, and rustup.
+- [Docker multi-platform images](https://docs.docker.com/build/building/multi-platform/): the containerd image store is what holds several platforms of one image.
diff --git a/scripts/desktop/binaries.json b/scripts/desktop/binaries.json
index 8b6ea544..7ed53968 100644
--- a/scripts/desktop/binaries.json
+++ b/scripts/desktop/binaries.json
@@ -23,31 +23,71 @@
"sha256": "1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599"
}
},
- "linuxBundleTools": [
- {
- "file": "AppRun-x86_64",
- "url": "https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-x86_64",
- "sha256": "f30140a43a0a59e46db21bdefdf749b9e9f2c6946e92afabbacf98b8ae73fb4f"
- },
- {
- "file": "linuxdeploy-x86_64.AppImage",
- "url": "https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy/linuxdeploy-x86_64.AppImage",
- "sha256": "20eebde3c18ae2e44279bd624fc72482503aece216d5d77f10932235342f71c1"
- },
- {
- "file": "linuxdeploy-plugin-gtk.sh",
- "url": "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/b5eb8d05b4c0ed40107fe2158c5d8527f94568ef/linuxdeploy-plugin-gtk.sh",
- "sha256": "cb379f9b0733e9ad9f8bd78f8c2fa038aef2478523bb7d4c8e64ff6a1ea3501a"
- },
- {
- "file": "linuxdeploy-plugin-gstreamer.sh",
- "url": "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gstreamer/2a2e67491c32995a3f279ad0ecbe77abd512b42a/linuxdeploy-plugin-gstreamer.sh",
- "sha256": "c107b49d84edbffc6ab226ed1007e0626a4f7aa2c3a36b7782bef62351d49e94"
+ "linux-arm64": {
+ "target": "aarch64-unknown-linux-gnu",
+ "node": {
+ "url": "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-arm64.tar.gz",
+ "sha256": "3515603e2487879a39bc75716f1a2affd027500c64ba50e845cf72cb33219013"
},
- {
- "file": "linuxdeploy-plugin-appimage.AppImage",
- "url": "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage",
- "sha256": "0441769ab38009504d2678c38cd7e526955388dd30a215b4a20afaa5471652f2"
+ "rg": {
+ "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-unknown-linux-gnu.tar.gz",
+ "sha256": "2b661c6ef508e902f388e9098d9c4c5aca72c87b55922d94abdba830b4dc885e"
}
- ]
+ },
+ "linuxBundleTools": {
+ "x64": [
+ {
+ "file": "AppRun-x86_64",
+ "url": "https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-x86_64",
+ "sha256": "f30140a43a0a59e46db21bdefdf749b9e9f2c6946e92afabbacf98b8ae73fb4f"
+ },
+ {
+ "file": "linuxdeploy-x86_64.AppImage",
+ "url": "https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy/linuxdeploy-x86_64.AppImage",
+ "sha256": "e762bea85c8eb0d4b3508d46e5c1f037f717d0f9303ae3b4aafc8b04991fa1ef"
+ },
+ {
+ "file": "linuxdeploy-plugin-gtk.sh",
+ "url": "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/b5eb8d05b4c0ed40107fe2158c5d8527f94568ef/linuxdeploy-plugin-gtk.sh",
+ "sha256": "cb379f9b0733e9ad9f8bd78f8c2fa038aef2478523bb7d4c8e64ff6a1ea3501a"
+ },
+ {
+ "file": "linuxdeploy-plugin-gstreamer.sh",
+ "url": "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gstreamer/2a2e67491c32995a3f279ad0ecbe77abd512b42a/linuxdeploy-plugin-gstreamer.sh",
+ "sha256": "c107b49d84edbffc6ab226ed1007e0626a4f7aa2c3a36b7782bef62351d49e94"
+ },
+ {
+ "file": "linuxdeploy-plugin-appimage.AppImage",
+ "url": "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage",
+ "sha256": "49d6a17160675a6bd1781699aae6bdf7692d98552e02a3671d2183d10547842e"
+ }
+ ],
+ "arm64": [
+ {
+ "file": "AppRun-aarch64",
+ "url": "https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-aarch64",
+ "sha256": "072f17c0895a85c490282fe5395c5007e5fc75da727e553b3b8fb680feb11578"
+ },
+ {
+ "file": "linuxdeploy-aarch64.AppImage",
+ "url": "https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy/linuxdeploy-aarch64.AppImage",
+ "sha256": "b12b5cc57bd0921e1f98d73f58aa364503bc1a27f54b7a69fd2870bce7fa2f55"
+ },
+ {
+ "file": "linuxdeploy-plugin-gtk.sh",
+ "url": "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/b5eb8d05b4c0ed40107fe2158c5d8527f94568ef/linuxdeploy-plugin-gtk.sh",
+ "sha256": "cb379f9b0733e9ad9f8bd78f8c2fa038aef2478523bb7d4c8e64ff6a1ea3501a"
+ },
+ {
+ "file": "linuxdeploy-plugin-gstreamer.sh",
+ "url": "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gstreamer/2a2e67491c32995a3f279ad0ecbe77abd512b42a/linuxdeploy-plugin-gstreamer.sh",
+ "sha256": "c107b49d84edbffc6ab226ed1007e0626a4f7aa2c3a36b7782bef62351d49e94"
+ },
+ {
+ "file": "linuxdeploy-plugin-appimage.AppImage",
+ "url": "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-aarch64.AppImage",
+ "sha256": "518f4b49a561c157b23fd1ba4e857b8fa2271a038ac711bdedab6f018acbe7e5"
+ }
+ ]
+ }
}
diff --git a/scripts/desktop/bundle-tools.mjs b/scripts/desktop/bundle-tools.mjs
index abcd17aa..9aa01b42 100644
--- a/scripts/desktop/bundle-tools.mjs
+++ b/scripts/desktop/bundle-tools.mjs
@@ -6,14 +6,15 @@ import { execFileSync } from "node:child_process";
const pins = JSON.parse(
await fs.readFile(new URL("./binaries.json", import.meta.url), "utf8"),
);
-if (process.platform !== "linux" || process.arch !== "x64")
- throw new Error("Linux bundle helpers require x64 Linux");
+const tools = pins.linuxBundleTools[process.arch];
+if (process.platform !== "linux" || !tools)
+ throw new Error("Linux bundle helpers require x64 or arm64 Linux");
const cache = path.join(
process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"),
"tauri",
);
await fs.mkdir(cache, { recursive: true });
-for (const tool of pins.linuxBundleTools) {
+for (const tool of tools) {
const file = path.join(cache, tool.file);
try {
await fs.access(file);
@@ -47,7 +48,7 @@ const inventoryFile = new URL(
import.meta.url,
);
const inventory = JSON.parse(await fs.readFile(inventoryFile, "utf8"));
-inventory.bundleTools = pins.linuxBundleTools;
+inventory.bundleTools = tools;
await fs.writeFile(inventoryFile, JSON.stringify(inventory, null, 2) + "\n");
console.log(
"Verified all Linux bundle helper checksums before Tauri packaging",
diff --git a/scripts/desktop/licenses.mjs b/scripts/desktop/licenses.mjs
index a8e4cb23..e2580bb8 100644
--- a/scripts/desktop/licenses.mjs
+++ b/scripts/desktop/licenses.mjs
@@ -57,10 +57,7 @@ async function visit(directory) {
}
await visit(path.join(root, "apps/desktop"));
const native = path.join(root, "apps/desktop/src-tauri");
-const triple =
- process.platform === "darwin"
- ? "aarch64-apple-darwin"
- : "x86_64-unknown-linux-gnu";
+const triple = inventory.downloads.target;
const metadata = JSON.parse(
execFileSync(
"cargo",
diff --git a/scripts/desktop/manifest.mjs b/scripts/desktop/manifest.mjs
index a656809d..6a22705c 100644
--- a/scripts/desktop/manifest.mjs
+++ b/scripts/desktop/manifest.mjs
@@ -26,7 +26,8 @@ await walk(directory);
const platforms = {};
for (const [platform, suffix, required] of [
["darwin-aarch64", ".app.tar.gz", true],
- ["linux-x86_64", ".AppImage", false],
+ ["linux-x86_64", "_amd64.AppImage", true],
+ ["linux-aarch64", "_aarch64.AppImage", false],
]) {
const file = all.find((file) => file.endsWith(suffix));
if (!file && !required) continue;
diff --git a/scripts/desktop/publish-release.mjs b/scripts/desktop/publish-release.mjs
index c8e42ddb..f13ddd05 100644
--- a/scripts/desktop/publish-release.mjs
+++ b/scripts/desktop/publish-release.mjs
@@ -29,8 +29,12 @@ for (const suffix of [
".app.tar.gz",
".app.tar.gz.sig",
"_ios.ipa",
+ "_amd64.deb",
+ "_amd64.AppImage",
+ "_amd64.AppImage.sig",
"remote-runtime-darwin-arm64.tar.gz",
"remote-runtime-linux-x64.tar.gz",
+ "remote-runtime-linux-arm64.tar.gz",
"remote-runtime-manifest.json",
])
if (!files.some((file) => file.endsWith(suffix)))
diff --git a/scripts/desktop/run.mjs b/scripts/desktop/run.mjs
index b01adcef..084803ef 100644
--- a/scripts/desktop/run.mjs
+++ b/scripts/desktop/run.mjs
@@ -9,9 +9,13 @@ if (process.platform === "darwin")
if (!["dev", "build", "native-build", "check"].includes(mode))
throw new Error("Choose dev, build, native-build, or check");
if (
- !["darwin-arm64", "linux-x64"].includes(`${process.platform}-${process.arch}`)
+ !["darwin-arm64", "linux-x64", "linux-arm64"].includes(
+ `${process.platform}-${process.arch}`,
+ )
)
- throw new Error("Desktop builds support macOS arm64 and Ubuntu 24.04 x64");
+ throw new Error(
+ "Desktop builds support macOS arm64 and Ubuntu 24.04 x64 or arm64",
+ );
if (mode !== "check") {
run(process.execPath, ["scripts/desktop/prepare.mjs"]);
run(process.execPath, ["scripts/desktop/smoke-runtime.mjs"]);
diff --git a/scripts/ios/ssh-live.mjs b/scripts/ios/ssh-live.mjs
index d3e29a05..be534cab 100644
--- a/scripts/ios/ssh-live.mjs
+++ b/scripts/ios/ssh-live.mjs
@@ -26,13 +26,18 @@ const freePort = () => new Promise((resolve, reject) => {
}).on("error", reject);
});
+// OXBIT_SSH_TEST_PLATFORM=linux/arm64 runs the fixture and payload for arm64 servers.
+const dockerPlatform = process.env.OXBIT_SSH_TEST_PLATFORM ?? "linux/amd64";
+const remotePlatform = { "linux/amd64": "linux-x64", "linux/arm64": "linux-arm64" }[dockerPlatform];
+if (!remotePlatform) throw new Error(`Unsupported OXBIT_SSH_TEST_PLATFORM: ${dockerPlatform}`);
// The remote runtime test needs `bun run remote:prepare`; the host downloads from this server.
const payload = join(root, "apps/desktop/src-tauri/resources/runtime/remote");
-const hasPayload = existsSync(join(payload, "linux-x64.tar.gz"));
+const archive = `${remotePlatform}.tar.gz`;
+const hasPayload = existsSync(join(payload, archive));
const files = createHttpServer((request, response) => {
- if (request.url !== "/linux-x64.tar.gz") return response.writeHead(404).end();
+ if (request.url !== `/${archive}`) return response.writeHead(404).end();
response.writeHead(200, { "content-type": "application/gzip" });
- createReadStream(join(payload, "linux-x64.tar.gz")).pipe(response);
+ createReadStream(join(payload, archive)).pipe(response);
});
await new Promise((resolve) => files.listen(0, "127.0.0.1", resolve));
if (!hasPayload) console.log("live_remote_runtime skipped: run `bun run remote:prepare` first");
@@ -42,10 +47,10 @@ const name = `oxbit-ios-ssh-${randomBytes(4).toString("hex")}`;
const passphrase = randomBytes(12).toString("hex");
const password = randomBytes(12).toString("hex");
try {
- run("docker", ["build", "--platform", "linux/amd64", "-f", "scripts/remote/sshd.Dockerfile", "-t", "oxbit-ssh-test:local", "scripts/remote"], { cwd: root });
+ run("docker", ["build", "--platform", dockerPlatform, "-f", "scripts/remote/sshd.Dockerfile", "-t", "oxbit-ssh-test:local", "scripts/remote"], { cwd: root });
run("ssh-keygen", ["-q", "-t", "ed25519", "-N", passphrase, "-C", "oxbit-live", "-f", join(temp, "identity")]);
const [port, passwordPort] = [await freePort(), await freePort()];
- docker("run", "-d", "--name", name, "--platform", "linux/amd64",
+ docker("run", "-d", "--name", name, "--platform", dockerPlatform,
...(process.platform === "linux" ? ["--add-host", "host.docker.internal:host-gateway"] : []), "-p", `127.0.0.1:${port}:22`, "-p", `127.0.0.1:${passwordPort}:2222`,
"-v", `${join(temp, "identity.pub")}:/test-key:ro`, "oxbit-ssh-test:local");
docker("exec", name, "sh", "-c",
@@ -63,6 +68,7 @@ try {
OXBIT_SSH_TEST_PASSPHRASE: passphrase,
OXBIT_SSH_TEST_PASSWORD: password,
OXBIT_SSH_TEST_PAYLOAD_DIR: payload,
+ OXBIT_SSH_TEST_REMOTE_PLATFORM: remotePlatform,
OXBIT_SSH_TEST_PAYLOAD_URL: `http://host.docker.internal:${files.address().port}`,
},
});
diff --git a/scripts/language/package.ts b/scripts/language/package.ts
index 4173df67..b2c3be75 100644
--- a/scripts/language/package.ts
+++ b/scripts/language/package.ts
@@ -11,7 +11,7 @@ await fs.mkdir(output, { recursive: true });
const records = [];
for (const name of (await fs.readdir(cache)).sort()) {
// The proprietary server is always obtained directly from upstream, including in CI.
- if (name.startsWith("intelephense") || !/(?:darwin-arm64|linux-x64)$/.test(name)) continue;
+ if (name.startsWith("intelephense") || !/(?:darwin-arm64|linux-x64|linux-arm64)$/.test(name)) continue;
for (const digest of (await fs.readdir(path.join(cache, name))).sort()) {
if (!/^[a-f0-9]{64}$/.test(digest)) continue;
const directory = path.join(cache, name, digest);
diff --git a/scripts/remote/prepare.mjs b/scripts/remote/prepare.mjs
index 53fdbe5d..ce4dc654 100644
--- a/scripts/remote/prepare.mjs
+++ b/scripts/remote/prepare.mjs
@@ -55,12 +55,22 @@ async function unpack(binary) {
return dir;
}
const manifest = { version: 1, platforms: {} };
-const targets = process.env.OXBIT_REMOTE_TARGETS?.split(",") ?? [
- "darwin-arm64",
- "linux-x64",
-];
+const supported = ["darwin-arm64", "linux-x64", "linux-arm64"];
+// Per-platform manifests of node:24.20.0-bookworm (index sha256:be23f54a…). Docker's classic
+// image store keeps one platform per index digest, so building both Linux targets needs these.
+const dockerBuilds = {
+ "linux-x64": {
+ platform: "linux/amd64",
+ image: "node:24.20.0-bookworm@sha256:9137a20e25879e0b557227b57e3ee4e9af4bde29eb3db66134cd1723e84f830b",
+ },
+ "linux-arm64": {
+ platform: "linux/arm64",
+ image: "node:24.20.0-bookworm@sha256:78b162211207872503ea9245188122b815150b9b4380e47a7c4a447332c01660",
+ },
+};
+const targets = process.env.OXBIT_REMOTE_TARGETS?.split(",") ?? supported;
for (const platform of targets) {
- if (!["darwin-arm64", "linux-x64"].includes(platform))
+ if (!supported.includes(platform))
throw new Error(`Unsupported remote target: ${platform}`);
if (process.env.OXBIT_REMOTE_PAYLOADS) {
const supplied = path.join(
@@ -101,8 +111,8 @@ for (const platform of targets) {
await fs.rm(tool, { recursive: true, force: true });
}
}
- // The npm package includes macOS prebuilds; Linux is built for Node 24 in a
- // Linux build environment. Nothing is compiled or downloaded on the SSH host.
+ // The npm package includes macOS prebuilds; Linux is built for Node 24 on a
+ // Linux host of the target architecture. Nothing is compiled on the SSH host.
const pty = path.join(stage, "node_modules/node-pty");
const original = path.join(
root,
@@ -119,7 +129,7 @@ for (const platform of targets) {
path.join(pty, "prebuilds", platform, "spawn-helper"),
0o755,
);
- } else if (process.platform === "linux" && process.arch === "x64") {
+ } else if (`${process.platform}-${process.arch}` === platform) {
await fs.cp(
path.join(root, "apps/runtime/node_modules/node-pty/build"),
path.join(pty, "build"),
@@ -130,19 +140,19 @@ for (const platform of targets) {
"run",
"--rm",
"--platform",
- "linux/amd64",
+ dockerBuilds[platform].platform,
"-v",
`${stage}:/runtime`,
"-w",
"/runtime/node_modules/node-pty",
- `node:${pins.nodeVersion}-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2`,
+ dockerBuilds[platform].image,
"node",
"/usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js",
"rebuild",
"--nodedir=/usr/local",
]);
}
- if (platform === "linux-x64") {
+ if (platform.startsWith("linux")) {
const addon = await fs.readFile(path.join(pty, "build/Release/pty.node"));
await fs.rm(path.join(pty, "build"), { recursive: true, force: true });
await fs.mkdir(path.join(pty, "build/Release"), { recursive: true });
diff --git a/scripts/remote/release-manifest.mjs b/scripts/remote/release-manifest.mjs
index d422bc38..4547c71b 100644
--- a/scripts/remote/release-manifest.mjs
+++ b/scripts/remote/release-manifest.mjs
@@ -4,7 +4,7 @@ import path from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
-export const remotePlatforms = ["darwin-arm64", "linux-x64"];
+export const remotePlatforms = ["darwin-arm64", "linux-x64", "linux-arm64"];
export async function buildReleaseManifest(directories, version) {
const found = {};
diff --git a/scripts/remote/smoke.ts b/scripts/remote/smoke.ts
index 65be60ad..c8da746f 100644
--- a/scripts/remote/smoke.ts
+++ b/scripts/remote/smoke.ts
@@ -18,6 +18,16 @@ const run = (cmd: string, args: string[]) =>
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const docker = (...args: string[]) => run("docker", args);
+// OXBIT_SSH_TEST_PLATFORM=linux/arm64 checks the linux-arm64 payload on an arm64 host.
+const dockerPlatform = process.env.OXBIT_SSH_TEST_PLATFORM ?? "linux/amd64";
+const remoteName = (
+ { "linux/amd64": "Linux x64", "linux/arm64": "Linux arm64" } as Record<
+ string,
+ string
+ >
+)[dockerPlatform];
+if (!remoteName)
+ throw new Error(`Unsupported OXBIT_SSH_TEST_PLATFORM: ${dockerPlatform}`);
const checks: string[] = [];
let connection: Awaited> | undefined;
let socket: any;
@@ -81,7 +91,7 @@ try {
[
"build",
"--platform",
- "linux/amd64",
+ dockerPlatform,
"-f",
"scripts/remote/sshd.Dockerfile",
"-t",
@@ -105,7 +115,7 @@ try {
"--name",
fixture,
"--platform",
- "linux/amd64",
+ dockerPlatform,
"--cap-add",
"NET_ADMIN",
"-p",
@@ -313,7 +323,7 @@ try {
JSON.stringify(
{
result: "passed",
- remote: "Linux x64 Debian bookworm, isolated Docker SSH server",
+ remote: `${remoteName} Debian bookworm, isolated Docker SSH server`,
checks,
},
null,
diff --git a/tests/browser/themes.spec.ts b/tests/browser/themes.spec.ts
index a50d856f..dc4d4db6 100644
--- a/tests/browser/themes.spec.ts
+++ b/tests/browser/themes.spec.ts
@@ -508,7 +508,7 @@ test("rendered Markdown, popovers and keyboard focus use the selected theme", as
test("bundled creative themes and Rainbow icons switch, pair and persist", async ({
page,
-}) => {
+}, testInfo) => {
await ready(page);
await page.evaluate(async () => {
const z = (window as any).__oxbit;
@@ -554,7 +554,7 @@ test("bundled creative themes and Rainbow icons switch, pair and persist", async
"A workbench for everyone.",
);
await page.screenshot({
- path: "/private/tmp/oxbit-creative-" + id + ".png",
+ path: testInfo.outputPath(`oxbit-creative-${id}.png`),
});
}
await page.evaluate(() =>
@@ -589,7 +589,7 @@ test("bundled creative themes and Rainbow icons switch, pair and persist", async
);
expect(broken).not.toContain(true);
await page.screenshot({
- path: "/private/tmp/oxbit-creative-rainbow-icons-dark.png",
+ path: testInfo.outputPath("oxbit-creative-rainbow-icons-dark.png"),
});
await page.evaluate(() => (window as any).__oxbit.runCommand("theme.toggle"));
expect(await selected(page)).toBe("oxbit.creative/rainbow-light");
@@ -598,7 +598,7 @@ test("bundled creative themes and Rainbow icons switch, pair and persist", async
"light",
);
await page.screenshot({
- path: "/private/tmp/oxbit-creative-rainbow-icons-light.png",
+ path: testInfo.outputPath("oxbit-creative-rainbow-icons-light.png"),
});
await page.reload();
await page.waitForFunction(() => !!(window as any).__oxbit?.ready);
diff --git a/tests/ios/ssh-runtime.spec.ts b/tests/ios/ssh-runtime.spec.ts
index 4869e8c7..e91fac80 100644
--- a/tests/ios/ssh-runtime.spec.ts
+++ b/tests/ios/ssh-runtime.spec.ts
@@ -92,13 +92,13 @@ test("Start Oxbit on This Server shows the desktop progress, opens the workspace
});
test("A failed start keeps the dialog open with the error and stops the runtime", async ({ page }) => {
- await start(page, { runtimeFailure: "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon." });
+ await start(page, { runtimeFailure: "Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon." });
await openDialog(page);
await page.getByRole("button", { name: "Start", exact: true }).click();
await expect(page.getByRole("status").filter({ hasText: "Installing the remote runtime…" })).toBeVisible();
await release(page);
const dialog = page.getByRole("dialog", { name: "Start Oxbit on This Server", exact: true });
- await expect(dialog.getByRole("alert")).toHaveText("Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon.");
+ await expect(dialog.getByRole("alert")).toHaveText("Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon.");
await expect.poll(() => calls(page)).toContain("ios_ssh_runtime_stop");
await shoot(page, "runtime-error");
});
diff --git a/tests/remote-release-manifest.test.ts b/tests/remote-release-manifest.test.ts
index 28f77db3..6301a772 100644
--- a/tests/remote-release-manifest.test.ts
+++ b/tests/remote-release-manifest.test.ts
@@ -29,28 +29,31 @@ afterEach(() => {
describe("remote runtime release manifest", () => {
it("combines per-platform artifact directories with sizes and the version", async () => {
const linux = payloads({ "linux-x64": "linux payload" });
+ const arm = payloads({ "linux-arm64": "arm payload" });
const darwin = payloads({ "darwin-arm64": "darwin" });
- const { manifest } = await buildReleaseManifest([linux, darwin], "0.4.0-alpha.1");
+ const { manifest } = await buildReleaseManifest([linux, arm, darwin], "0.4.0-alpha.1");
expect(manifest).toEqual({
version: "0.4.0-alpha.1",
platforms: {
"darwin-arm64": { sha256: sha256(Buffer.from("darwin")), size: 6 },
"linux-x64": { sha256: sha256(Buffer.from("linux payload")), size: 13 },
+ "linux-arm64": { sha256: sha256(Buffer.from("arm payload")), size: 11 },
},
});
});
it("copies the archives byte for byte and writes the manifest", async () => {
- const bundled = payloads({ "darwin-arm64": "signed darwin", "linux-x64": "linux" });
+ const bundled = payloads({ "darwin-arm64": "signed darwin", "linux-x64": "linux", "linux-arm64": "arm" });
const output = fs.mkdtempSync(path.join(os.tmpdir(), "oxbit-release-files-"));
temps.push(output);
const manifest = await writeReleaseAssets(output, [bundled], "0.3.4");
expect(fs.readdirSync(output).sort()).toEqual([
"remote-runtime-darwin-arm64.tar.gz",
+ "remote-runtime-linux-arm64.tar.gz",
"remote-runtime-linux-x64.tar.gz",
"remote-runtime-manifest.json",
]);
- for (const platform of ["darwin-arm64", "linux-x64"]) {
+ for (const platform of ["darwin-arm64", "linux-x64", "linux-arm64"]) {
const published = fs.readFileSync(path.join(output, `remote-runtime-${platform}.tar.gz`));
expect(published.equals(fs.readFileSync(path.join(bundled, `${platform}.tar.gz`)))).toBe(true);
expect(sha256(published)).toBe(manifest.platforms[platform].sha256);
@@ -59,8 +62,9 @@ describe("remote runtime release manifest", () => {
});
it("rejects an archive that does not match its manifest", async () => {
- const directory = payloads({ "darwin-arm64": "a", "linux-x64": "b" }, {
+ const directory = payloads({ "darwin-arm64": "a", "linux-x64": "b", "linux-arm64": "c" }, {
"darwin-arm64": { sha256: sha256(Buffer.from("a")) },
+ "linux-arm64": { sha256: sha256(Buffer.from("c")) },
"linux-x64": { sha256: sha256(Buffer.from("rebuilt")) },
});
await expect(buildReleaseManifest([directory], "0.3.4")).rejects.toThrow("Remote payload checksum mismatch: linux-x64");
@@ -68,7 +72,7 @@ describe("remote runtime release manifest", () => {
it("rejects missing, duplicate, and unknown platforms", async () => {
const linux = payloads({ "linux-x64": "linux" });
- await expect(buildReleaseManifest([linux], "0.3.4")).rejects.toThrow("Missing remote payloads: darwin-arm64");
+ await expect(buildReleaseManifest([linux], "0.3.4")).rejects.toThrow("Missing remote payloads: darwin-arm64, linux-arm64");
await expect(buildReleaseManifest([linux, linux], "0.3.4")).rejects.toThrow("Duplicate remote platform: linux-x64");
const intel = payloads({ "darwin-x64": "intel" });
await expect(buildReleaseManifest([intel], "0.3.4")).rejects.toThrow("Unsupported remote platform: darwin-x64");