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..55057fb7 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,17 @@ See [npm packages](docs/npm.md). | `oxbit -f` | Keep the runtime attached to the terminal | | `oxbit --no-open` | Start without opening a browser | | `oxbit --port 9300` | Choose a port for a new runtime; stop an existing one first | +| `oxbit --lan` | Serve on your network and advertise the runtime to the iOS app | +| `oxbit --keep-alive 1h` | Stop the runtime after an hour with no connected client | | `oxbit --help` | Show all options | -The first runtime uses port 9277. Later runtimes choose a free port and record it -in `~/.oxbit/workspaces//daemon.json`. The printed URL includes a pairing code -in its fragment, which the editor consumes and removes from the address bar. -Opening `http://127.0.0.1:9277` without that fragment loads the browser sample -workspace; use **Runtime connection** to pair it with your project. +Each runtime binds a port the operating system assigns, unless `--port` or `PORT` +sets one, and records it in `~/.oxbit/workspaces//daemon.json`. The printed URL +includes a pairing code in its fragment, which the editor consumes and removes from +the address bar. Opening the runtime URL without that fragment loads the browser +sample workspace; the cloud button in the title bar opens the **Runtime** page, +where you pair the runtime with its printed code. Later, one tap on the cloud button +reconnects to the last runtime. Without a global install, run `OXBIT_WORKSPACE=/absolute/path bun run start` after building. See [runtime configuration](docs/runtime.md) for network hosting, @@ -62,11 +66,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. @@ -82,7 +86,7 @@ GitHub tokens and runtime pairing credentials are stored in Keychain. Run `bun run ios:dev "Oxbit iPhone"` or `bun run ios:simulator` on macOS with Xcode. See [iOS setup and runtime connections](docs/ios.md). -The [documentation index](docs/INDEX.md) links native source control, SSH and SFTP, and language-server guides with official API references. +The [documentation index](docs/INDEX.md) links native source control, SSH and SFTP, language-server, runtime discovery and keep-alive guides with official API references. The [iOS release artwork guide](docs/ios-release-assets.md) covers icon exports, App Store screenshots, and promo exports with Apple's design and size references. @@ -155,7 +159,8 @@ watchers. | `examples/` | Extension and icon-pack examples | See [architecture](docs/architecture.md), [feature status](docs/feature-status.md), -[dependencies](docs/dependencies.md), and [design assets](design/README.md). +[dependencies](docs/dependencies.md), [dependency audits](docs/security.md#dependency-audit), +and [design assets](design/README.md). ### Checks diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f7e99e3c..b426f43d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,7 +19,8 @@ "react": "19.1.0", "react-dom": "19.1.0", "@tauri-apps/api": "2.11.1", - "@oxbit/themes": "workspace:*" + "@oxbit/themes": "workspace:*", + "@oxbit/protocol": "workspace:*" }, "devDependencies": { "@tauri-apps/cli": "2.11.4", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 85ebeb9e..6c1dcc62 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3407,9 +3407,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index edb86049..59c9a9d4 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -290,6 +290,7 @@ pub async fn desktop_connection( window: WebviewWindow, key: String, restart: Option, + keep_alive: Option, ) -> Result { let project = authorized(&app, &window, &key)?; tauri::async_runtime::spawn_blocking(move || { @@ -313,8 +314,8 @@ pub async fn desktop_connection( let progress_app = app.clone(); let progress_key = key.clone(); let process = OwnedRuntime::launch(&resource.join("bin/node"), &resource.join("desktop.js"), Launch { version: 1, r#type: "launch", root: &project.path, data_dir: &data.to_string_lossy(), workspace_key: &key, token: &token, - rg_path: &resource.join("bin/rg").to_string_lossy(), git_path: git.as_deref(), development: cfg!(debug_assertions) && !cfg!(feature = "custom-protocol"), - }, &desktop.environment, move || { let _ = event_app.emit("desktop-runtime-failed", json!({"key":event_key})); }, move |message| { let _ = progress_app.emit("desktop-remote-progress", json!({"key":progress_key,"message":message})); })?; + rg_path: &resource.join("bin/rg").to_string_lossy(), git_path: git.as_deref(), development: cfg!(debug_assertions) && !cfg!(feature = "custom-protocol"), keep_alive, + }, &desktop.environment, move |detail| { let _ = event_app.emit("desktop-runtime-failed", json!({"key":event_key,"detail":detail})); }, move |message| { let _ = progress_app.emit("desktop-remote-progress", json!({"key":progress_key,"message":message})); })?; // Ownership can change while the runtime starts. Never return credentials to a former owner. desktop.model.lock().unwrap().authorize(window.label(), &key)?; let result = json!({"url":format!("http://127.0.0.1:{}", process.port), "token":token, "key":key, "openFile":process.open_file}); diff --git a/apps/desktop/src-tauri/src/supervisor.rs b/apps/desktop/src-tauri/src/supervisor.rs index f4ab7358..d7d1ebc1 100644 --- a/apps/desktop/src-tauri/src/supervisor.rs +++ b/apps/desktop/src-tauri/src/supervisor.rs @@ -1,11 +1,11 @@ use crate::environment::Environment; use serde::{Deserialize, Serialize}; use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, io::{BufRead, BufReader, Write}, os::unix::process::CommandExt, path::Path, - process::{Child, ChildStdin, Command, Stdio}, + process::{Child, ChildStderr, ChildStdin, Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, @@ -26,8 +26,13 @@ pub struct Launch<'a> { pub rg_path: &'a str, pub git_path: Option<&'a str>, pub development: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub keep_alive: Option, } +pub const OUTPUT_SEPARATOR: &str = "\n--- runtime output ---\n"; +const OUTPUT_LIMIT: usize = 4096; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Frame { @@ -66,13 +71,62 @@ fn terminate_groups(groups: &Mutex>) { } } +fn drain_output(stderr: ChildStderr, tail: Arc>>) -> thread::JoinHandle<()> { + let tee = std::env::var_os("OXBIT_RUNTIME_STDERR").is_some(); + thread::spawn(move || { + let mut stderr = stderr; + let mut chunk = [0u8; 4096]; + loop { + let count = match std::io::Read::read(&mut stderr, &mut chunk) { + Ok(0) => break, + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + }; + if tee { + let _ = std::io::stderr().write_all(&chunk[..count]); + } + let mut tail = tail.lock().unwrap(); + tail.extend(&chunk[..count]); + let excess = tail.len().saturating_sub(OUTPUT_LIMIT); + tail.drain(..excess); + } + }) +} + +// Grandchildren outside the process group can hold the pipe open, so EOF is awaited briefly. +fn settle_output(drain: thread::JoinHandle<()>) { + let deadline = Instant::now() + Duration::from_millis(200); + while !drain.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } +} + +fn output_text(tail: &Mutex>) -> String { + let bytes: Vec = tail.lock().unwrap().iter().copied().collect(); + let text: String = String::from_utf8_lossy(&bytes) + .chars() + .filter(|c| !c.is_control() || *c == '\n' || *c == '\t') + .collect(); + text.trim().to_string() +} + +fn with_output(message: String, tail: &Mutex>) -> String { + let output = output_text(tail); + if output.is_empty() { + message + } else { + format!("{message}{OUTPUT_SEPARATOR}{output}") + } +} + impl OwnedRuntime { pub fn launch( node: &Path, entry: &Path, config: Launch<'_>, environment: &Environment, - crashed: impl Fn() + Send + Sync + 'static, + crashed: impl Fn(String) + Send + Sync + 'static, progress: impl Fn(&str), ) -> Result, String> { let timeout = if config.root.starts_with("ssh://") { @@ -88,7 +142,7 @@ impl OwnedRuntime { entry: &Path, config: Launch<'_>, environment: &Environment, - crashed: impl Fn() + Send + Sync + 'static, + crashed: impl Fn(String) + Send + Sync + 'static, progress: impl Fn(&str), timeout: Duration, ) -> Result, String> { @@ -103,18 +157,17 @@ impl OwnedRuntime { .env_remove("OXBIT_LSP_COMMAND") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - // Runtime failures reach the user as an RPC message with no stack behind it. - // OXBIT_RUNTIME_STDERR keeps the child's diagnostics when one needs chasing. - .stderr(if std::env::var_os("OXBIT_RUNTIME_STDERR").is_some() { - Stdio::inherit() - } else { - Stdio::null() - }) + // Startup errors and crash events carry the stderr tail. + // OXBIT_RUNTIME_STDERR also copies the child's stderr to the parent's. + .stderr(Stdio::piped()) .process_group(0) .spawn() .map_err(|_| "Bundled runtime could not be started. Reinstall Oxbit.")?; let mut input = child.stdin.take().ok_or("Runtime input is unavailable")?; let stdout = child.stdout.take().ok_or("Runtime output is unavailable")?; + let stderr = child.stderr.take().ok_or("Runtime output is unavailable")?; + let tail = Arc::new(Mutex::new(VecDeque::new())); + let drain = drain_output(stderr, tail.clone()); let frame = serde_json::to_vec(&config).map_err(|_| "Invalid launch configuration")?; if input .write_all(&frame) @@ -123,7 +176,8 @@ impl OwnedRuntime { { let _ = child.kill(); let _ = child.wait(); - return Err("Runtime launch channel closed".into()); + settle_output(drain); + return Err(with_output("Runtime launch channel closed".into(), &tail)); } let (send, receive) = mpsc::channel::(); thread::spawn(move || { @@ -190,10 +244,12 @@ impl OwnedRuntime { } let _ = child.wait(); terminate_groups(&startup_groups); - return Err(detail.unwrap_or_else(|| { + settle_output(drain); + let message = detail.unwrap_or_else(|| { "Runtime startup failed or timed out. Retry or reinstall the application." .into() - })); + }); + return Err(with_output(message, &tail)); } }; let runtime = Arc::new(Self { @@ -235,10 +291,11 @@ impl OwnedRuntime { while let Ok(frame) = receive.try_recv() { runtime.observe(frame); } - runtime.alive.store(false, Ordering::SeqCst); terminate_groups(&runtime.groups); + settle_output(drain); + runtime.alive.store(false, Ordering::SeqCst); if !runtime.stopped.load(Ordering::SeqCst) { - crashed(); + crashed(output_text(&tail)); } break; } @@ -350,7 +407,7 @@ mod tests { } fn launch( &self, - crashed: impl Fn() + Send + Sync + 'static, + crashed: impl Fn(String) + Send + Sync + 'static, ) -> Result, String> { let mut env = Environment::new(); env.insert("PATH".into(), "/usr/bin:/bin".into()); @@ -358,17 +415,7 @@ mod tests { OwnedRuntime::launch_with_timeout( Path::new("/bin/sh"), &self.directory.join("entry.sh"), - Launch { - version: 1, - r#type: "launch", - root: "/tmp/workspace", - data_dir: "/tmp/state", - workspace_key: "project", - token: "private-token", - rg_path: "/bin/false", - git_path: None, - development: false, - }, + config(None), &env, crashed, |_| {}, @@ -382,6 +429,20 @@ mod tests { .unwrap() } } + fn config(keep_alive: Option) -> Launch<'static> { + Launch { + version: 1, + r#type: "launch", + root: "/tmp/workspace", + data_dir: "/tmp/state", + workspace_key: "project", + token: "private-token", + rg_path: "/bin/false", + git_path: None, + development: false, + keep_alive, + } + } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.directory); @@ -397,7 +458,7 @@ mod tests { "[ -z \"${{NODE_OPTIONS+x}}\" ] || exit 1\n{READY}\nIFS= read -r shutdown" )); let runtime = fixture - .launch(|| panic!("graceful stop must not report a crash")) + .launch(|_| panic!("graceful stop must not report a crash")) .unwrap(); assert_eq!(runtime.port, 12345); runtime.stop(); @@ -411,7 +472,7 @@ mod tests { echo '{{\"version\":1,\"type\":\"process\",\"pid\":'\"$$\"',\"running\":false}}'\n\ {READY}\nIFS= read -r shutdown" )); - let runtime = fixture.launch(|| {}).unwrap(); + let runtime = fixture.launch(|_| {}).unwrap(); assert_eq!(runtime.port, 12345); assert!(runtime.groups.lock().unwrap().is_empty()); runtime.stop(); @@ -426,12 +487,40 @@ mod tests { ] { let fixture = Fixture::new(&body); let start = Instant::now(); - assert!(fixture.launch(|| {}).is_err()); + assert!(fixture.launch(|_| {}).is_err()); assert!(start.elapsed() < Duration::from_secs(2)); assert!(gone(fixture.pid())); } } #[test] + fn exit_before_ready_reports_runtime_output() { + let fixture = + Fixture::new("echo 'boom from runtime' >&2\nprintf '\\033[31mred\\033[0m' >&2\nexit 3"); + let error = fixture.launch(|_| {}).err().unwrap(); + assert!(error.starts_with("Runtime startup failed or timed out.")); + assert!(error.ends_with(&format!("{OUTPUT_SEPARATOR}boom from runtime\n[31mred[0m"))); + } + #[test] + fn crash_detail_carries_runtime_output() { + let fixture = Fixture::new(&format!("{READY}\necho 'fatal after ready' >&2\nexit 1")); + let (send, receive) = mpsc::channel(); + let send = Mutex::new(send); + let _runtime = fixture + .launch(move |detail| { + let _ = send.lock().unwrap().send(detail); + }) + .unwrap(); + let detail = receive.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(detail, "fatal after ready"); + } + #[test] + fn keep_alive_serializes_only_when_set() { + let omitted = serde_json::to_string(&config(None)).unwrap(); + assert!(!omitted.contains("keepAlive")); + let zero = serde_json::to_string(&config(Some(0))).unwrap(); + assert!(zero.contains("\"keepAlive\":0")); + } + #[test] fn abrupt_exit_reports_failure_once_and_cleans_inherited_children() { let fixture = Fixture::new(&format!( "{READY}\nsleep 30 &\nprintf '%s' \"$!\" > child-pid\nsleep 0.1\nexit 1" @@ -446,7 +535,7 @@ mod tests { let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let observed = calls.clone(); let runtime = fixture - .launch(move || { + .launch(move |_| { observed.fetch_add(1, Ordering::SeqCst); }) .unwrap(); diff --git a/apps/desktop/src/desktop.css b/apps/desktop/src/desktop.css index 9b255657..406ddb0c 100644 --- a/apps/desktop/src/desktop.css +++ b/apps/desktop/src/desktop.css @@ -131,6 +131,15 @@ html[data-native-titlebar="macos"][data-window-fullscreen="true"] .desktop-shell } .desktop-alert > span { flex: 1; + min-width: 0; +} +.desktop-alert pre { + max-height: 200px; + overflow: auto; + margin: 6px 0 0; + white-space: pre-wrap; + font-family: var(--font-mono); + font-size: var(--font-mono-font-size); } .desktop-dirty-list { max-height: 200px; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b8658420..52dd3168 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -17,7 +17,8 @@ import { ProjectMenu } from "./project-menu.js"; import "@oxbit/ui/tokens.css"; import "@oxbit/ui/workbench.css"; import "./desktop.css"; -import { SshDialog } from "./ssh-dialog.js"; +import { SshDialog, sshWorkspaceUri } from "./ssh-dialog.js"; +import { splitRuntimeError } from "@oxbit/protocol"; (globalThis as any).__OXBIT_REACT__ = ReactHost; installTextInputPolicy(document); @@ -94,7 +95,7 @@ function App() { const view = useSyncExternalStore(manager.subscribe, manager.snapshot); const active = manager.active; const [error, setError] = useState(""); - const [panel, setPanel] = useState<"trust" | "tools" | "update" | "ssh">(); + const [panel, setPanel] = useState<"tools" | "update" | "ssh">(); const [tools, setTools] = useState([]); const [update, setUpdate] = useState(); const [busy, setBusy] = useState(false); @@ -283,6 +284,15 @@ function App() { off?.(); }; }, []); + useEffect(() => { + manager.ssh = { + manageSsh: () => setPanel("ssh"), + startSsh: async (authority, path) => { + const [, host, port] = /^(.*?)(?::(\d+))?$/.exec(authority)!; + await native.openRemote(sshWorkspaceUri(host, path, port)); + }, + }; + }, []); useEffect(() => { const keyboard = (event: KeyboardEvent) => { if ( @@ -319,6 +329,7 @@ function App() { const registrations = [ ["workspace.open", "Open Folder…", () => native.open()], ["workspace.connectSsh", "Connect over SSH…", () => setPanel("ssh")], + ["workspace.runtime", "Runtime", () => session.workbench.run("runtime.cloud")], [ "workspace.switch", "Switch Project", @@ -427,7 +438,7 @@ function App() { )} {active?.error && (
- {active.error} +
); } +function RuntimeError({ text }: { text: string }) { + const { message, detail } = splitRuntimeError(text); + return ( + + {message} + {detail &&
Details
{detail}
} +
+ ); +} function ProjectPanels({ entry, active, children }: { entry: ReturnType["projects"][number]; active: boolean; children: ReactNode }) { const workbench = entry.session!.workbench; useEffect(() => { @@ -696,41 +708,6 @@ function ActiveWorkbench({ /> ); } -function TrustDialog({ - session, - close, -}: { - session: Session; - close: () => void; -}) { - const [trusted, setTrusted] = useState(!!session.runtime?.session?.trusted); - const [error, setError] = useState(""); - return ( - -

- Opening a folder enables editing. Trust this project to run terminals, - tasks, Git, language services, and runtime extensions. -

-

- {trusted - ? "This project is trusted." - : "This project’s tools are disabled."} -

- {error &&

{error}

} - -
- ); -} async function startGhLogin(session: Session) { const gh = (await native.tools()).find((tool) => tool.name === "gh")?.path; if (!gh) 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/desktop/tsconfig.json b/apps/desktop/tsconfig.json index d1601348..b130d88b 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../packages/themes" + }, + { + "path": "../../packages/protocol" } ] } diff --git a/apps/ios/package.json b/apps/ios/package.json index 715ca9cc..827f8cee 100644 --- a/apps/ios/package.json +++ b/apps/ios/package.json @@ -18,7 +18,9 @@ "@oxbit/themes": "workspace:*", "@tauri-apps/api": "2.11.1", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "@oxbit/feature-runtime": "workspace:*", + "@oxbit/protocol": "workspace:*" }, "devDependencies": { "@tauri-apps/cli": "2.11.4" diff --git a/apps/ios/plugins/oxbit-files/build.rs b/apps/ios/plugins/oxbit-files/build.rs index 7225d653..3b585007 100644 --- a/apps/ios/plugins/oxbit-files/build.rs +++ b/apps/ios/plugins/oxbit-files/build.rs @@ -5,6 +5,7 @@ const COMMANDS: &[&str] = &[ "forget_folder", "documents_path", "runtime_credentials", + "runtime_discovery", "git_credentials", "ssh_keys", "pick_files", diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift b/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift index fecc8822..1eb993b2 100644 --- a/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift +++ b/apps/ios/plugins/oxbit-files/ios/Sources/OxbitFilesPlugin.swift @@ -23,6 +23,7 @@ enum FolderError: LocalizedError { /// Owns the document picker and the security-scoped URLs. Rust reads and writes the resolved paths. class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate { private let runtime = RuntimeCredentials() + private let discovery = RuntimeDiscovery() private let languageServers = LanguageServers() private let git = GitCredentials() private let sshKeys = SshKeys() @@ -45,6 +46,10 @@ class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate { runtime.handle(invoke) } + @objc public func runtimeDiscovery(_ invoke: Invoke) { + discovery.handle(invoke) + } + @objc public func lspMessage(_ invoke: Invoke) { languageServers.handle(invoke) } diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeCredentials.swift b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeCredentials.swift index 2986ee7f..d3ca69d4 100644 --- a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeCredentials.swift +++ b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeCredentials.swift @@ -5,10 +5,17 @@ import Tauri private struct RuntimeCredentialArgs: Decodable { let operation: String let url: String + let runtimeId: String? let code: String? } -/// Pair outside the WebView's cookie store and keep reconnect credentials in the device Keychain. +private struct PairResult: Encodable { + let token: String + let runtime: RuntimeIdentity? +} + +/// Pair outside the WebView's cookie store and keep reconnect credentials in the device Keychain, +/// keyed by runtime id so a port change keeps the token. URL accounts move to the id on first use. final class RuntimeCredentials: NSObject, URLSessionTaskDelegate { private let service = "com.yannelli.oxbit.runtime" private lazy var session: URLSession = { @@ -22,22 +29,23 @@ final class RuntimeCredentials: NSObject, URLSessionTaskDelegate { func handle(_ invoke: Invoke) { do { let args = try invoke.parseArgs(RuntimeCredentialArgs.self) - guard let components = URLComponents(string: args.url), - ["http", "https"].contains(components.scheme ?? ""), - let host = components.host, !host.isEmpty, - components.user == nil, components.password == nil, - components.query == nil, components.fragment == nil, - components.path.isEmpty || components.path == "/", - let url = components.url - else { throw failure("Enter an http:// or https:// runtime address without a path or credentials.") } - let account = url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = RuntimeAccount.origin(args.url) else { + throw failure("Enter an http:// or https:// runtime address without a path or credentials.") + } + if let runtimeId = args.runtimeId, !RuntimeIdentity.validId(runtimeId) { + throw failure("The runtime id is invalid.") + } + let account = RuntimeAccount.url(url) + let idAccount = args.runtimeId.map(RuntimeAccount.id) switch args.operation { case "get": - invoke.resolve(try read(account).map { ["token": $0] } ?? [:]) + invoke.resolve(try get(idAccount: idAccount, urlAccount: account).map { ["token": $0] } ?? [:]) case "forget": - let status = SecItemDelete(query(account) as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { throw keychainError(status) } + if let idAccount { try delete(idAccount) } + try delete(account) invoke.resolve([String: String]()) + case "health": + health(url, invoke) case "pair": guard let code = args.code?.trimmingCharacters(in: .whitespacesAndNewlines), !code.isEmpty else { throw failure("Enter the pairing code shown by your runtime.") @@ -45,36 +53,11 @@ final class RuntimeCredentials: NSObject, URLSessionTaskDelegate { if code.hasPrefix("grant:") { let token = String(code.dropFirst(6)) guard !token.isEmpty else { throw failure("The runtime grant is empty.") } - try save(token, account: account) + try save(token, account: idAccount ?? account) invoke.resolve(["token": token]) return } - var request = URLRequest(url: url.appendingPathComponent("api/pair")) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: ["code": code]) - session.dataTask(with: request) { data, response, error in - do { - if let error { throw error } - guard let response = response as? HTTPURLResponse, let data, data.count <= 1_048_576 else { - throw self.failure("The runtime returned an invalid pairing response.") - } - let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - guard (200..<300).contains(response.statusCode) else { - let message = (json?["error"] as? [String: Any])?["message"] as? String - ?? json?["error"] as? String - ?? "Pairing failed (HTTP \(response.statusCode)). Check the address and pairing code." - throw self.failure(message) - } - guard let token = json?["token"] as? String, !token.isEmpty else { - throw self.failure("The runtime did not return a pairing token.") - } - try self.save(token, account: account) - invoke.resolve(["token": token]) - } catch { - invoke.reject(error.localizedDescription, code: "RUNTIME_PAIRING") - } - }.resume() + pair(url, code: code, urlAccount: account, invoke) default: throw failure("Unknown runtime credential operation.") } @@ -83,6 +66,84 @@ final class RuntimeCredentials: NSObject, URLSessionTaskDelegate { } } + private func get(idAccount: String?, urlAccount: String) throws -> String? { + guard let idAccount else { return try read(urlAccount) } + if let token = try read(idAccount) { return token } + guard let token = try read(urlAccount) else { return nil } + try save(token, account: idAccount) + try delete(urlAccount) + return token + } + + private func pair(_ url: URL, code: String, urlAccount: String, _ invoke: Invoke) { + var request = URLRequest(url: url.appendingPathComponent("api/pair")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + do { + request.httpBody = try JSONSerialization.data(withJSONObject: ["code": code]) + } catch { + invoke.reject(error.localizedDescription, code: "RUNTIME_PAIRING") + return + } + session.dataTask(with: request) { data, response, error in + do { + let json = try self.json(data, response, error, fallback: "Pairing failed") { + "Pairing failed (HTTP \($0)). Check the address and pairing code." + } + guard let token = json["token"] as? String, !token.isEmpty else { + throw self.failure("The runtime did not return a pairing token.") + } + let runtime = RuntimeIdentity(json: json["runtime"]) + if let runtime { + try self.save(token, account: RuntimeAccount.id(runtime.id)) + try self.delete(urlAccount) + } else { + try self.save(token, account: urlAccount) + } + invoke.resolve(PairResult(token: token, runtime: runtime)) + } catch { + invoke.reject(error.localizedDescription, code: "RUNTIME_PAIRING") + } + }.resume() + } + + /// The WebView cannot read `/api/health` across origins, so the identity check runs here. + private func health(_ url: URL, _ invoke: Invoke) { + let request = URLRequest(url: url.appendingPathComponent("api/health"), timeoutInterval: 5) + session.dataTask(with: request) { data, response, error in + do { + let json = try self.json(data, response, error, fallback: "The runtime is unreachable") { + "The runtime health check failed (HTTP \($0))." + } + guard json["protocol"] as? Int == 1, let runtime = RuntimeIdentity(json: json) else { + throw self.failure("The runtime did not report its identity. Update Oxbit on that computer.") + } + invoke.resolve(["runtime": runtime]) + } catch { + invoke.reject(error.localizedDescription, code: "RUNTIME_HEALTH") + } + }.resume() + } + + private func json(_ data: Data?, _ response: URLResponse?, _ error: Error?, fallback: String, + status: (Int) -> String) throws -> [String: Any] { + if let error { + throw failure("\(fallback): \(error.localizedDescription)") + } + guard let response = response as? HTTPURLResponse, let data, data.count <= 1_048_576 else { + throw failure("The runtime returned an invalid response.") + } + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + guard (200..<300).contains(response.statusCode) else { + let message = (json?["error"] as? [String: Any])?["message"] as? String + ?? json?["error"] as? String + ?? status(response.statusCode) + throw failure(message) + } + guard let json else { throw failure("The runtime returned an invalid response.") } + return json + } + // A redirect must never forward an owner pairing code to a different endpoint. func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, @@ -109,6 +170,11 @@ final class RuntimeCredentials: NSObject, URLSessionTaskDelegate { return token } + private func delete(_ account: String) throws { + let status = SecItemDelete(query(account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw keychainError(status) } + } + private func save(_ token: String, account: String) throws { let values: [String: Any] = [kSecValueData as String: Data(token.utf8), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDiscovery.swift b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDiscovery.swift new file mode 100644 index 00000000..c5e56a32 --- /dev/null +++ b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDiscovery.swift @@ -0,0 +1,143 @@ +import Foundation +import Network +import Tauri + +private struct RuntimeDiscoveryArgs: Decodable { + let operation: String +} + +private struct RuntimeDiscoveryList: Encodable { + let runtimes: [DiscoveredRuntime] +} + +/// Browses `_oxbit._tcp` in `local.` and resolves each service to an address the WebView can open. +/// The WebView polls `list`; browsing ends on `stop` or after two minutes without `start` or `list`. +final class RuntimeDiscovery { + private static let idle: TimeInterval = 120 + private static let resolveTimeout: TimeInterval = 5 + private let queue = DispatchQueue(label: "com.yannelli.oxbit.runtime-discovery") + private var browser: NWBrowser? + private var wanted = false + private var idleTimer: DispatchWorkItem? + private var services: [NWEndpoint: [String: String]] = [:] + private var resolved: [NWEndpoint: DiscoveredRuntime] = [:] + private var probes: [NWEndpoint: NWConnection] = [:] + + func handle(_ invoke: Invoke) { + let operation: String + do { + operation = try invoke.parseArgs(RuntimeDiscoveryArgs.self).operation + } catch { + invoke.reject(error.localizedDescription, code: "RUNTIME_DISCOVERY") + return + } + queue.async { + switch operation { + case "start": + self.wanted = true + self.browse() + case "list": + if self.wanted { self.browse() } + case "stop": + self.wanted = false + self.cancel() + default: + invoke.reject("Unknown runtime discovery operation.", code: "RUNTIME_DISCOVERY") + return + } + invoke.resolve(RuntimeDiscoveryList(runtimes: DiscoveredRuntime.unique(Array(self.resolved.values)))) + } + } + + private func browse() { + idleTimer?.cancel() + let timer = DispatchWorkItem { [weak self] in self?.cancel() } + idleTimer = timer + queue.asyncAfter(deadline: .now() + Self.idle, execute: timer) + guard browser == nil else { return } + let browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_oxbit._tcp", domain: nil), using: NWParameters()) + browser.stateUpdateHandler = { [weak self, weak browser] state in + guard case .failed = state, let self, let browser, self.browser === browser else { return } + self.cancel() + } + browser.browseResultsChangedHandler = { [weak self] results, _ in self?.update(results) } + self.browser = browser + browser.start(queue: queue) + } + + private func cancel() { + idleTimer?.cancel() + idleTimer = nil + browser?.cancel() + browser = nil + probes.values.forEach { $0.cancel() } + probes.removeAll() + services.removeAll() + resolved.removeAll() + } + + private func update(_ results: Set) { + var current: [NWEndpoint: [String: String]] = [:] + for result in results { + guard case .bonjour(let txt) = result.metadata else { continue } + current[result.endpoint] = txt.dictionary + } + for endpoint in services.keys where current[endpoint] == nil { + probes.removeValue(forKey: endpoint)?.cancel() + resolved.removeValue(forKey: endpoint) + } + for (endpoint, txt) in current where services[endpoint] != txt { + resolved.removeValue(forKey: endpoint) + probes.removeValue(forKey: endpoint)?.cancel() + if txt["id"] != nil { resolve(endpoint, txt: txt, ipv4: true) } + } + services = current + } + + /// A short-lived connection reports the address Bonjour resolved; IPv4 first, then any family. + private func resolve(_ endpoint: NWEndpoint, txt: [String: String], ipv4: Bool) { + let parameters = NWParameters.tcp + if ipv4, let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { + ip.version = .v4 + } + let connection = NWConnection(to: endpoint, using: parameters) + probes[endpoint] = connection + let finish = { [weak self, weak connection] (address: (String, UInt16)?) in + guard let self, let connection, self.probes[endpoint] === connection else { return } + connection.cancel() + self.probes.removeValue(forKey: endpoint) + if let (host, port) = address { + self.resolved[endpoint] = DiscoveredRuntime(txt: txt, service: Self.service(endpoint), host: host, port: port) + } else if ipv4 { + self.resolve(endpoint, txt: txt, ipv4: false) + } + } + connection.stateUpdateHandler = { [weak connection] state in + switch state { + case .ready: + guard case .hostPort(let host, let port) = connection?.currentPath?.remoteEndpoint else { return finish(nil) } + finish((Self.host(host), port.rawValue)) + case .failed, .waiting: + finish(nil) + default: + break + } + } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + Self.resolveTimeout) { finish(nil) } + } + + private static func host(_ host: NWEndpoint.Host) -> String { + switch host { + case .ipv4(let address): return "\(address)" + case .ipv6(let address): return "\(address)" + case .name(let name, _): return name + @unknown default: return "" + } + } + + private static func service(_ endpoint: NWEndpoint) -> String { + if case .service(let name, _, _, _) = endpoint { return name } + return "" + } +} diff --git a/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeRecords.swift b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeRecords.swift new file mode 100644 index 00000000..7211258b --- /dev/null +++ b/apps/ios/plugins/oxbit-files/ios/Sources/RuntimeRecords.swift @@ -0,0 +1,94 @@ +import Foundation + +/// `RuntimeIdentity` from packages/protocol: `/api/health` and `/api/pair` report it. +struct RuntimeIdentity: Codable, Equatable { + let id: String + let name: String + let version: String + let startedAt: Double + + static func validId(_ id: String) -> Bool { + (8...64).contains(id.count) + && id.unicodeScalars.allSatisfy { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || $0 == "-") } + } + + init?(json: Any?) { + guard let json = json as? [String: Any], + let id = json["id"] as? String, RuntimeIdentity.validId(id), + let name = json["name"] as? String, let version = json["version"] as? String, + let startedAt = json["startedAt"] as? NSNumber + else { return nil } + self.init(id: id, name: name, version: version, startedAt: startedAt.doubleValue) + } + + init(id: String, name: String, version: String, startedAt: Double) { + self.id = id + self.name = name + self.version = version + self.startedAt = startedAt + } +} + +/// Keychain accounts: `runtime-id:` for a known runtime, the normalized URL before that. +enum RuntimeAccount { + static func id(_ runtimeId: String) -> String { "runtime-id:\(runtimeId)" } + + static func url(_ url: URL) -> String { + url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + /// An http or https origin without credentials, path, query, or fragment. + static func origin(_ string: String) -> URL? { + guard let components = URLComponents(string: string), + ["http", "https"].contains(components.scheme ?? ""), + let host = components.host, !host.isEmpty, + components.user == nil, components.password == nil, + components.query == nil, components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + return components.url + } +} + +/// A runtime found by Bonjour, in the shape of host-ios `DiscoveredRuntime`. +struct DiscoveredRuntime: Encodable, Equatable { + let runtimeId: String + let name: String + let version: String? + let host: String + let port: Int + let url: String + + /// `txt` holds the `_oxbit._tcp` TXT keys; the resolved port wins over the TXT `port`. + init?(txt: [String: String], service: String, host: String, port: UInt16?) { + guard let id = txt["id"], RuntimeIdentity.validId(id) else { return nil } + let host = DiscoveredRuntime.host(host) + guard !host.isEmpty, let port = port.map(Int.init) ?? txt["port"].flatMap(Int.init), + (1...65535).contains(port) + else { return nil } + runtimeId = id + name = txt["name"].flatMap { $0.isEmpty ? nil : $0 } ?? service + version = txt["version"].flatMap { $0.isEmpty ? nil : $0 } + self.host = host + self.port = port + url = DiscoveredRuntime.url(host: host, port: port) + } + + static func host(_ value: String) -> String { + String(value.split(separator: "%", maxSplits: 1).first ?? "") + } + + static func url(host: String, port: Int) -> String { + host.contains(":") ? "http://[\(host)]:\(port)" : "http://\(host):\(port)" + } + + /// One entry per runtime id, IPv4 first, ordered by name. + static func unique(_ runtimes: [DiscoveredRuntime]) -> [DiscoveredRuntime] { + var byId: [String: DiscoveredRuntime] = [:] + for runtime in runtimes { + if let kept = byId[runtime.runtimeId], !kept.host.contains(":") || runtime.host.contains(":") { continue } + byId[runtime.runtimeId] = runtime + } + return byId.values.sorted { ($0.name, $0.runtimeId) < ($1.name, $1.runtimeId) } + } +} diff --git a/apps/ios/plugins/oxbit-files/ios/Tests/RuntimeRecords/main.swift b/apps/ios/plugins/oxbit-files/ios/Tests/RuntimeRecords/main.swift new file mode 100644 index 00000000..a70fff08 --- /dev/null +++ b/apps/ios/plugins/oxbit-files/ios/Tests/RuntimeRecords/main.swift @@ -0,0 +1,63 @@ +import Foundation + +struct Failure: LocalizedError { + let errorDescription: String? +} + +func expect(_ value: @autoclosure () -> Bool, _ message: String) throws { + guard value() else { throw Failure(errorDescription: message) } +} + +func runTests() throws { + try expect(RuntimeIdentity.validId("runtime-0123"), "Accepts letters, digits, and dashes") + try expect(!RuntimeIdentity.validId("short"), "Rejects ids under 8 characters") + try expect(!RuntimeIdentity.validId(String(repeating: "a", count: 65)), "Rejects ids over 64 characters") + try expect(!RuntimeIdentity.validId("runtime:0123"), "Rejects separators") + try expect(!RuntimeIdentity.validId("runtimé-0123"), "Rejects non-ASCII letters") + + let json = try JSONSerialization.jsonObject(with: Data( + #"{"ok":true,"protocol":1,"id":"runtime-0123","name":"mac","version":"0.5.0","startedAt":1760000000000}"#.utf8)) + try expect(RuntimeIdentity(json: json) == RuntimeIdentity(id: "runtime-0123", name: "mac", version: "0.5.0", + startedAt: 1_760_000_000_000), "Reads the identity from a health body") + try expect(RuntimeIdentity(json: ["id": "bad", "name": "mac", "version": "1", "startedAt": 1]) == nil, + "Rejects an invalid id") + try expect(RuntimeIdentity(json: ["id": "runtime-0123", "name": "mac"]) == nil, "Rejects a partial identity") + + try expect(RuntimeAccount.id("runtime-0123") == "runtime-id:runtime-0123", "Id account") + let origin = RuntimeAccount.origin("http://192.168.1.10:9277/") + try expect(origin.map(RuntimeAccount.url) == "http://192.168.1.10:9277", "URL account drops the trailing slash") + try expect(RuntimeAccount.origin("https://runtime.example.test").map(RuntimeAccount.url) + == "https://runtime.example.test", "HTTPS origin") + for invalid in ["ftp://host", "http://", "http://user:pw@host", "http://host/path", "http://host?q=1", "http://host#x"] { + try expect(RuntimeAccount.origin(invalid) == nil, "Rejects \(invalid)") + } + + let txt = ["id": "runtime-0123", "name": "mac", "version": "0.5.0", "port": "9277"] + let v4 = DiscoveredRuntime(txt: txt, service: "Oxbit on mac", host: "192.168.1.10", port: 9277) + try expect(v4?.url == "http://192.168.1.10:9277" && v4?.name == "mac" && v4?.version == "0.5.0", "IPv4 runtime") + let v6 = DiscoveredRuntime(txt: txt, service: "Oxbit on mac", host: "fe80::1%en0", port: 9277) + try expect(v6?.host == "fe80::1" && v6?.url == "http://[fe80::1]:9277", "IPv6 drops the scope and gets brackets") + try expect(DiscoveredRuntime(txt: txt, service: "s", host: "10.0.0.2", port: nil)?.port == 9277, + "Falls back to the TXT port") + try expect(DiscoveredRuntime(txt: txt, service: "s", host: "10.0.0.2", port: 4000)?.port == 4000, + "Resolved port wins over TXT") + try expect(DiscoveredRuntime(txt: ["name": "mac"], service: "s", host: "10.0.0.2", port: 1) == nil, + "Drops entries without a TXT id") + try expect(DiscoveredRuntime(txt: ["id": "runtime-0123"], service: "Oxbit", host: "10.0.0.2", port: 1)?.name + == "Oxbit" && DiscoveredRuntime(txt: ["id": "runtime-0123"], service: "Oxbit", host: "10.0.0.2", port: 1)?.version + == nil, "Missing name uses the service name") + + let other = DiscoveredRuntime(txt: ["id": "runtime-4567", "name": "aaa"], service: "s", host: "10.0.0.3", port: 1)! + let unique = DiscoveredRuntime.unique([v6!, other, v4!]) + try expect(unique.map(\.runtimeId) == ["runtime-4567", "runtime-0123"], "One entry per id, ordered by name") + try expect(unique[1].host == "192.168.1.10", "IPv4 replaces IPv6 for the same id") + try expect(DiscoveredRuntime.unique([v4!, v6!])[0].host == "192.168.1.10", "IPv4 is kept over a later IPv6") +} + +do { + try runTests() + print("RuntimeRecords tests passed") +} catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) +} diff --git a/apps/ios/plugins/oxbit-files/permissions/autogenerated/commands/runtime_discovery.toml b/apps/ios/plugins/oxbit-files/permissions/autogenerated/commands/runtime_discovery.toml new file mode 100644 index 00000000..bb0cf891 --- /dev/null +++ b/apps/ios/plugins/oxbit-files/permissions/autogenerated/commands/runtime_discovery.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-runtime-discovery" +description = "Enables the runtime_discovery command without any pre-configured scope." +commands.allow = ["runtime_discovery"] + +[[permission]] +identifier = "deny-runtime-discovery" +description = "Denies the runtime_discovery command without any pre-configured scope." +commands.deny = ["runtime_discovery"] diff --git a/apps/ios/plugins/oxbit-files/permissions/autogenerated/reference.md b/apps/ios/plugins/oxbit-files/permissions/autogenerated/reference.md index 1a5988bf..425d0a53 100644 --- a/apps/ios/plugins/oxbit-files/permissions/autogenerated/reference.md +++ b/apps/ios/plugins/oxbit-files/permissions/autogenerated/reference.md @@ -1,6 +1,6 @@ ## Default Permission -Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, and list SSH key metadata. +Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, list SSH key metadata, and find Oxbit runtimes on the local network. #### This default permission set includes the following: @@ -10,6 +10,7 @@ Pick Files app folders and files, reopen folders from bookmarks, locate the Docu - `allow-forget-folder` - `allow-documents-path` - `allow-runtime-credentials` +- `allow-runtime-discovery` - `allow-git-credentials` - `allow-ssh-keys` - `allow-pick-files` @@ -261,6 +262,32 @@ Denies the runtime_credentials command without any pre-configured scope. +`oxbit-files:allow-runtime-discovery` + + + + +Enables the runtime_discovery command without any pre-configured scope. + + + + + + + +`oxbit-files:deny-runtime-discovery` + + + + +Denies the runtime_discovery command without any pre-configured scope. + + + + + + + `oxbit-files:allow-ssh-keys` diff --git a/apps/ios/plugins/oxbit-files/permissions/default.toml b/apps/ios/plugins/oxbit-files/permissions/default.toml index 468729b8..d65a8179 100644 --- a/apps/ios/plugins/oxbit-files/permissions/default.toml +++ b/apps/ios/plugins/oxbit-files/permissions/default.toml @@ -1,7 +1,7 @@ "$schema" = "schemas/schema.json" [default] -description = "Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, and list SSH key metadata." +description = "Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, list SSH key metadata, and find Oxbit runtimes on the local network." permissions = [ "allow-pick-folder", "allow-open-folder", @@ -9,6 +9,7 @@ permissions = [ "allow-forget-folder", "allow-documents-path", "allow-runtime-credentials", + "allow-runtime-discovery", "allow-git-credentials", "allow-ssh-keys", "allow-pick-files", diff --git a/apps/ios/plugins/oxbit-files/permissions/schemas/schema.json b/apps/ios/plugins/oxbit-files/permissions/schemas/schema.json index 9358a140..aee6dd5c 100644 --- a/apps/ios/plugins/oxbit-files/permissions/schemas/schema.json +++ b/apps/ios/plugins/oxbit-files/permissions/schemas/schema.json @@ -402,6 +402,18 @@ "const": "deny-runtime-credentials", "markdownDescription": "Denies the runtime_credentials command without any pre-configured scope." }, + { + "description": "Enables the runtime_discovery command without any pre-configured scope.", + "type": "string", + "const": "allow-runtime-discovery", + "markdownDescription": "Enables the runtime_discovery command without any pre-configured scope." + }, + { + "description": "Denies the runtime_discovery command without any pre-configured scope.", + "type": "string", + "const": "deny-runtime-discovery", + "markdownDescription": "Denies the runtime_discovery command without any pre-configured scope." + }, { "description": "Enables the ssh_keys command without any pre-configured scope.", "type": "string", @@ -415,10 +427,10 @@ "markdownDescription": "Denies the ssh_keys command without any pre-configured scope." }, { - "description": "Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, and list SSH key metadata.\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-open-folder`\n- `allow-close-folder`\n- `allow-forget-folder`\n- `allow-documents-path`\n- `allow-runtime-credentials`\n- `allow-git-credentials`\n- `allow-ssh-keys`\n- `allow-pick-files`\n- `allow-commit-signing`", + "description": "Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, list SSH key metadata, and find Oxbit runtimes on the local network.\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-open-folder`\n- `allow-close-folder`\n- `allow-forget-folder`\n- `allow-documents-path`\n- `allow-runtime-credentials`\n- `allow-runtime-discovery`\n- `allow-git-credentials`\n- `allow-ssh-keys`\n- `allow-pick-files`\n- `allow-commit-signing`", "type": "string", "const": "default", - "markdownDescription": "Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, and list SSH key metadata.\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-open-folder`\n- `allow-close-folder`\n- `allow-forget-folder`\n- `allow-documents-path`\n- `allow-runtime-credentials`\n- `allow-git-credentials`\n- `allow-ssh-keys`\n- `allow-pick-files`\n- `allow-commit-signing`" + "markdownDescription": "Pick Files app folders and files, reopen folders from bookmarks, locate the Documents directory, list SSH key metadata, and find Oxbit runtimes on the local network.\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-open-folder`\n- `allow-close-folder`\n- `allow-forget-folder`\n- `allow-documents-path`\n- `allow-runtime-credentials`\n- `allow-runtime-discovery`\n- `allow-git-credentials`\n- `allow-ssh-keys`\n- `allow-pick-files`\n- `allow-commit-signing`" } ] } diff --git a/apps/ios/plugins/oxbit-files/src/commands.rs b/apps/ios/plugins/oxbit-files/src/commands.rs index 5a7a6970..50d43341 100644 --- a/apps/ios/plugins/oxbit-files/src/commands.rs +++ b/apps/ios/plugins/oxbit-files/src/commands.rs @@ -41,6 +41,14 @@ pub(crate) async fn runtime_credentials( app.oxbit_files().runtime_credentials(request) } +#[command] +pub(crate) async fn runtime_discovery( + app: AppHandle, + request: serde_json::Value, +) -> Result { + app.oxbit_files().runtime_discovery(request) +} + #[command] pub(crate) async fn git_credentials( app: AppHandle, diff --git a/apps/ios/plugins/oxbit-files/src/desktop.rs b/apps/ios/plugins/oxbit-files/src/desktop.rs index 05ca2fd9..5598073f 100644 --- a/apps/ios/plugins/oxbit-files/src/desktop.rs +++ b/apps/ios/plugins/oxbit-files/src/desktop.rs @@ -71,6 +71,13 @@ impl OxbitFiles { Err(UNSUPPORTED) } + pub fn runtime_discovery( + &self, + _request: serde_json::Value, + ) -> crate::Result { + Err(UNSUPPORTED) + } + pub fn pick_folder(&self) -> crate::Result { Err(UNSUPPORTED) } diff --git a/apps/ios/plugins/oxbit-files/src/lib.rs b/apps/ios/plugins/oxbit-files/src/lib.rs index a8f939d5..0039116b 100644 --- a/apps/ios/plugins/oxbit-files/src/lib.rs +++ b/apps/ios/plugins/oxbit-files/src/lib.rs @@ -44,6 +44,7 @@ pub fn init() -> TauriPlugin { commands::forget_folder, commands::documents_path, commands::runtime_credentials, + commands::runtime_discovery, commands::git_credentials, commands::ssh_keys, commands::pick_files, diff --git a/apps/ios/plugins/oxbit-files/src/mobile.rs b/apps/ios/plugins/oxbit-files/src/mobile.rs index 2075825f..2ac124ba 100644 --- a/apps/ios/plugins/oxbit-files/src/mobile.rs +++ b/apps/ios/plugins/oxbit-files/src/mobile.rs @@ -110,6 +110,15 @@ impl OxbitFiles { .map_err(Into::into) } + pub fn runtime_discovery( + &self, + request: serde_json::Value, + ) -> crate::Result { + self.0 + .run_mobile_plugin("runtimeDiscovery", request) + .map_err(Into::into) + } + pub fn pick_folder(&self) -> crate::Result { self.0 .run_mobile_plugin("pickFolder", ()) diff --git a/apps/ios/src-tauri/Info.ios.plist b/apps/ios/src-tauri/Info.ios.plist index e42440af..b7182670 100644 --- a/apps/ios/src-tauri/Info.ios.plist +++ b/apps/ios/src-tauri/Info.ios.plist @@ -1,7 +1,8 @@ - NSLocalNetworkUsageDescriptionConnect to the Oxbit runtime on your computer to open projects and use terminals, Git, and language tools. + NSLocalNetworkUsageDescriptionFind Oxbit runtimes on your local network and connect to the one on your computer to open projects and use terminals, Git, and language tools. + NSBonjourServices_oxbit._tcp NSAppTransportSecurity NSAllowsLocalNetworking NSExceptionDomains diff --git a/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist b/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist index 08937772..b34b54f3 100644 --- a/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist +++ b/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.3.1 + 0.4.1 CFBundleVersion - 1 + 0.4.1 LSRequiresIPhoneOS UILaunchStoryboardName @@ -83,7 +83,7 @@ NSLocalNetworkUsageDescription - Connect to the Oxbit runtime on your computer to open projects and use terminals, Git, and language tools. + Find Oxbit runtimes on your local network and connect to the one on your computer to open projects and use terminals, Git, and language tools. NSAppTransportSecurity NSAllowsLocalNetworking @@ -137,5 +137,9 @@ + NSBonjourServices + + _oxbit._tcp + \ No newline at end of file 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_lease_tests.rs b/apps/ios/src-tauri/src/ssh/live_lease_tests.rs new file mode 100644 index 00000000..04269675 --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/live_lease_tests.rs @@ -0,0 +1,120 @@ +//! Live keep-alive lease on the Docker sshd fixture: after the SSH session drops, a runtime with +//! lease 0 keeps running and one with a short lease exits once it expires. +use super::{ + live_runtime_tests::{live, remote_platform, remote_runtimes, source, start, Live, Seen}, + live_tests::exec, + runtime_process::Event, + runtime_session::RemoteRuntime, +}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +/// Longer than the 10 s heartbeat, so only a dropped connection lets it expire. +const LEASE: u64 = 15_000; +/// Per-connection sshd on port 22: retitled `sshd: root@notty`, or the `-R` child under qemu. +const SESSION: &str = "'sshd: [r]oot|sshd -D.* -[R]$'"; + +fn processes(container: &str) -> String { + exec(container, "ps -eo pid,args") +} + +fn sessions(container: &str) -> String { + exec(container, &format!("pgrep -f {SESSION} || true")) +} + +/// Drops the last handle first so the closed channel finds no runtime to relaunch, then kills the +/// fixture's SSH sessions so no heartbeat or client remains. +async fn sever(container: &str, runtime: Arc) -> u32 { + let pid = runtime.pid().await.expect("the launch reports its PID"); + drop(runtime); + assert_ne!(sessions(container), "", "{}", processes(container)); + exec(container, &format!("pkill -9 -f {SESSION}")); + let deadline = Instant::now() + Duration::from_secs(5); + while !sessions(container).is_empty() && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(sessions(container), "", "{}", processes(container)); + pid +} + +async fn gone_within(container: &str, limit: Duration) -> Option { + let started = Instant::now(); + while started.elapsed() < limit { + if remote_runtimes(container).is_empty() { + return Some(started.elapsed()); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + None +} + +fn reconnected(seen: &Seen) -> bool { + seen.lock() + .unwrap() + .iter() + .any(|event| matches!(event, Event::Reconnecting { .. })) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the Docker sshd fixture"] +async fn live_remote_runtime_lease() { + let Live { + container, + served, + pool, + scratch, + .. + } = live().await; + let hosted = source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ); + + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted.clone(), &seen, Some(0)).await; + runtime.ensure(true).await.unwrap(); + let pid = sever(&container, runtime).await; + tokio::time::sleep(Duration::from_secs(15)).await; + assert_eq!( + remote_runtimes(&container), + pid.to_string(), + "{}", + processes(&container) + ); + assert!(!reconnected(&seen), "{:?}", seen.lock().unwrap()); + println!("PASS lease 0 keeps pid {pid} running 15 s after the SSH session dropped"); + exec(&container, &format!("kill -TERM {pid}")); + assert!( + gone_within(&container, Duration::from_secs(10)) + .await + .is_some(), + "{}", + processes(&container) + ); + + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted, &seen, Some(LEASE)).await; + runtime.ensure(true).await.unwrap(); + let pid = runtime.pid().await.expect("the launch reports its PID"); + tokio::time::sleep(Duration::from_millis(LEASE + 5000)).await; + assert_eq!( + remote_runtimes(&container), + pid.to_string(), + "{}", + processes(&container) + ); + println!("PASS heartbeats hold a {LEASE} ms lease past its length while connected"); + sever(&container, runtime).await; + tokio::time::sleep(Duration::from_secs(1)).await; + assert_eq!(remote_runtimes(&container), pid.to_string()); + let gone = gone_within(&container, Duration::from_secs(30)).await; + assert!(gone.is_some(), "{}", processes(&container)); + assert!(!reconnected(&seen), "{:?}", seen.lock().unwrap()); + println!( + "PASS a {LEASE} ms lease ends pid {pid} {:.1} s after the SSH session dropped", + 1.0 + gone.unwrap().as_secs_f64() + ); + std::fs::remove_dir_all(&scratch).unwrap(); +} 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..1a7c2477 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,11 +68,16 @@ 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(); - let runtime = start(&pool, "live", hosted(), &seen).await; + let runtime = start(&pool, "live", hosted(), &seen, None).await; let ready = runtime.ensure(true).await; exec(&container, "mv /usr/bin/curl.off /usr/bin/curl"); assert_eq!( @@ -98,7 +103,7 @@ async fn live_runtime_wget_tasks_and_budget() { ); let seen: Seen = Arc::default(); - let runtime = start(&pool, "live", hosted(), &seen).await; + let runtime = start(&pool, "live", hosted(), &seen, None).await; runtime.ensure(true).await.unwrap(); let pid = runtime.pid().await.unwrap(); let mark = seen.lock().unwrap().len(); 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..50d9fdb3 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| { @@ -60,6 +66,7 @@ pub(super) async fn start( host_id: &str, source: Source, seen: &Seen, + keep_alive: Option, ) -> Arc { let root = "/~/project".to_string(); let seen = seen.clone(); @@ -73,6 +80,7 @@ pub(super) async fn start( uuid::Uuid::new_v4().simple() ), ready_timeout: Duration::from_secs(120), + keep_alive, }; let events = Arc::new(move |event| seen.lock().unwrap().push(event)); RemoteRuntime::open(pool.clone(), source, events, options) @@ -201,7 +209,7 @@ async fn live_remote_runtime() { } = live().await; let seen: Seen = Arc::default(); let missing = source(format!("{served}/missing.tar.gz"), scratch.clone()); - let runtime = start(&pool, "live", missing, &seen).await; + let runtime = start(&pool, "live", missing, &seen, None).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!(ready.installed, Some(Install::DeviceStream)); assert_eq!(ready.root, "/root/project"); @@ -222,8 +230,11 @@ 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 runtime = start(&pool, "live", hosted.clone(), &seen).await; + let hosted = source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ); + let runtime = start(&pool, "live", hosted.clone(), &seen, None).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!( ready.installed, @@ -234,7 +245,7 @@ async fn live_remote_runtime() { println!("PASS host-download install with curl"); let seen: Seen = Arc::default(); - let runtime = start(&pool, "live", hosted, &seen).await; + let runtime = start(&pool, "live", hosted, &seen, None).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!(ready.installed, Some(Install::Cached)); assert_eq!(progress(&seen), [STARTING]); diff --git a/apps/ios/src-tauri/src/ssh/mod.rs b/apps/ios/src-tauri/src/ssh/mod.rs index ef892dba..0868ce2a 100644 --- a/apps/ios/src-tauri/src/ssh/mod.rs +++ b/apps/ios/src-tauri/src/ssh/mod.rs @@ -12,6 +12,8 @@ pub mod known_hosts; #[cfg(test)] mod live_git_tests; #[cfg(test)] +mod live_lease_tests; +#[cfg(test)] mod live_runtime_task_tests; #[cfg(test)] mod live_runtime_tests; @@ -24,6 +26,7 @@ mod runtime_process; pub mod runtime_protocol; #[cfg(test)] mod runtime_protocol_tests; +mod runtime_reattach; pub mod runtime_session; mod runtime_tunnel; pub mod session; diff --git a/apps/ios/src-tauri/src/ssh/runtime_commands.rs b/apps/ios/src-tauri/src/ssh/runtime_commands.rs index ee7fce7b..d97c28d4 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_commands.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_commands.rs @@ -86,6 +86,7 @@ pub async fn ios_ssh_runtime_start( id: String, host_id: String, path: String, + keep_alive: Option, ) -> Result { if id.is_empty() || id.len() > 64 || state.ssh.runtimes.lock().unwrap().contains_key(&id) { return Err(Error::invalid("Invalid remote workspace ID")); @@ -112,6 +113,7 @@ pub async fn ios_ssh_runtime_start( workspace_key, token, ready_timeout: READY_TIMEOUT, + keep_alive, }; let runtime = RemoteRuntime::open(state.ssh.pool.clone(), source, events, options).await?; state diff --git a/apps/ios/src-tauri/src/ssh/runtime_frames.rs b/apps/ios/src-tauri/src/ssh/runtime_frames.rs index 44f0c019..4c6ad3ab 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_frames.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_frames.rs @@ -11,11 +11,20 @@ fn invalid_response() -> Error { Error::new("REMOTE_RUNTIME", "Invalid remote runtime response") } -pub fn launch_frame(root: &str, workspace_key: &str, token: &str) -> String { - let frame = json!({ +/// `keep_alive` is the lease in milliseconds after the last client; 0 runs until stopped. +pub fn launch_frame( + root: &str, + workspace_key: &str, + token: &str, + keep_alive: Option, +) -> String { + let mut frame = json!({ "version": 1, "type": "launch", "remoteRuntime": true, "root": root, "workspaceKey": workspace_key, "token": token, "development": false, }); + if let Some(keep_alive) = keep_alive { + frame["keepAlive"] = json!(keep_alive); + } frame.to_string() + "\n" } diff --git a/apps/ios/src-tauri/src/ssh/runtime_process.rs b/apps/ios/src-tauri/src/ssh/runtime_process.rs index 57680219..f6e7133c 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_process.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_process.rs @@ -44,6 +44,8 @@ pub struct Options { pub workspace_key: String, pub token: String, pub ready_timeout: Duration, + /// Milliseconds the runtime keeps running after its last client; `None` uses its default. + pub keep_alive: Option, } #[derive(Debug, Clone, Serialize)] @@ -97,8 +99,9 @@ impl Stdout { } } +/// `writer` is `None` after a reattach: the runtime outlived the exec channel that launched it. pub struct Process { - pub writer: Writer, + pub writer: Option, pub alive: Arc, pub tasks: Vec>, } @@ -109,9 +112,11 @@ impl Process { for task in self.tasks { task.abort(); } - tokio::spawn(async move { - let _ = self.writer.close().await; - }); + if let Some(writer) = self.writer { + tokio::spawn(async move { + let _ = writer.close().await; + }); + } } } 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..5c2bc01c 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs @@ -1,5 +1,5 @@ use super::{ - runtime_frames::*, runtime_process::Budget, runtime_protocol::*, runtime_tunnel::healthy, + runtime_frames::*, runtime_process::Budget, runtime_protocol::*, runtime_tunnel::health_body, }; use serde_json::{json, Value}; use std::time::{Duration, Instant}; @@ -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) @@ -112,13 +117,18 @@ fn maps_the_folder_field_to_a_launch_root() { #[test] fn writes_launch_heartbeat_and_task_frames() { - let launch: Value = serde_json::from_str(&launch_frame("/~/app", KEY, "token")).unwrap(); + let launch: Value = serde_json::from_str(&launch_frame("/~/app", KEY, "token", None)).unwrap(); assert_eq!( launch, json!({ "version": 1, "type": "launch", "remoteRuntime": true, "root": "/~/app", "workspaceKey": KEY, "token": "token", "development": false }) ); - assert!(launch_frame("/", KEY, "t").ends_with("}\n")); + assert!(launch_frame("/", KEY, "t", None).ends_with("}\n")); + for keep_alive in [0, 75000, 3_600_000] { + let launch: Value = + serde_json::from_str(&launch_frame("/", KEY, "t", Some(keep_alive))).unwrap(); + assert_eq!(launch["keepAlive"], json!(keep_alive)); + } assert_eq!(HEARTBEAT_FRAME, "{\"version\":1,\"type\":\"heartbeat\"}\n"); let forwarded: Value = serde_json::from_str(&task_forwarded_frame("r1", Some(4100))).unwrap(); assert_eq!( @@ -227,6 +237,10 @@ fn splits_frames_across_reads_and_caps_a_line_at_64_kib() { assert!(FrameReader::default().push(&long).is_err()); } +fn healthy(response: &str) -> bool { + health_body(response).is_some() +} + #[test] fn accepts_only_a_protocol_1_health_response() { let ok = @@ -240,6 +254,9 @@ fn accepts_only_a_protocol_1_health_response() { "HTTP/1.1 403 Forbidden\r\n\r\n{\"ok\":true,\"protocol\":1}" )); assert!(!healthy("garbage")); + let identified = "HTTP/1.1 200 OK\r\n\r\n{\"ok\":true,\"protocol\":1,\"id\":\"runtime-0123\"}"; + assert_eq!(health_body(identified).unwrap()["id"], "runtime-0123"); + assert!(health_body(ok).unwrap()["id"].is_null()); } #[test] diff --git a/apps/ios/src-tauri/src/ssh/runtime_reattach.rs b/apps/ios/src-tauri/src/ssh/runtime_reattach.rs new file mode 100644 index 00000000..6c2d19bd --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/runtime_reattach.rs @@ -0,0 +1,85 @@ +//! Reattaching to a remote runtime that outlived its exec channel under its keep-alive lease. The +//! PID must still run our `desktop.js` and `/api/health` must report the id recorded at launch. +use super::runtime_protocol::sh; + +pub const REATTACHED: &str = "Reconnected to the running runtime."; + +/// The server loopback port and health `id` of the runtime's first ready. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attach { + pub port: u16, + pub runtime_id: String, +} + +/// Prints the process's command line, or nothing when the PID is gone. +pub fn alive_command(pid: u32) -> String { + sh(&format!("ps -p {pid} -o args= 2>/dev/null")) +} + +/// The same match as `stop_stale_command`: a `desktop.js` under the shared install layout. +pub fn ours(ps_args: &str) -> bool { + let args = ps_args.trim(); + args.find("/.oxbit/remote/runtimes/") + .is_some_and(|at| args[at..].contains("desktop.js")) +} + +/// A launch is reattachable when it recorded a PID and an attach point. +pub fn candidate(pid: Option, attach: Option<&Attach>) -> Option<(u32, Attach)> { + Some((pid?, attach?.clone())) +} + +pub fn can_reattach(ps_args: &str, attach: &Attach, health_id: Option<&str>) -> bool { + ours(ps_args) && health_id == Some(attach.runtime_id.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ARGS: &str = "/home/me/.oxbit/remote/runtimes/abc/bin/node /home/me/.oxbit/remote/runtimes/abc/desktop.js\n"; + + fn attach() -> Attach { + Attach { + port: 4100, + runtime_id: "runtime-0123".into(), + } + } + + #[test] + fn checks_the_pid_without_killing_it() { + let command = alive_command(42); + assert!(command.starts_with("sh -c ")); + assert!(command.contains("ps -p 42 -o args=")); + assert!(!command.contains("kill")); + } + + #[test] + fn recognizes_only_our_runtime_process() { + assert!(ours(ARGS)); + assert!(!ours("")); + assert!(!ours("/usr/bin/node /srv/app/desktop.js")); + assert!(!ours( + "desktop.js /home/me/.oxbit/remote/runtimes/abc/bin/node" + )); + } + + #[test] + fn tries_a_reattach_only_with_a_recorded_pid_and_attach_point() { + assert_eq!(candidate(Some(7), Some(&attach())), Some((7, attach()))); + assert_eq!(candidate(None, Some(&attach())), None); + assert_eq!(candidate(Some(7), None), None); + } + + #[test] + fn reattaches_only_to_the_same_live_runtime() { + assert!(can_reattach(ARGS, &attach(), Some("runtime-0123"))); + assert!(!can_reattach(ARGS, &attach(), Some("runtime-4567"))); + assert!(!can_reattach(ARGS, &attach(), None)); + assert!(!can_reattach("", &attach(), Some("runtime-0123"))); + assert!(!can_reattach( + "/usr/bin/python3 server.py", + &attach(), + Some("runtime-0123") + )); + } +} diff --git a/apps/ios/src-tauri/src/ssh/runtime_session.rs b/apps/ios/src-tauri/src/ssh/runtime_session.rs index 4df4d11e..92153dfd 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_session.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_session.rs @@ -7,6 +7,7 @@ use super::{ heartbeat, Budget, Event, Events, Options, Process, Ready, Stdout, TaskForwards, Writer, }, runtime_protocol as protocol, + runtime_reattach::{self as reattach, Attach}, runtime_tunnel::{health, Tunnel}, session::Pool, }; @@ -30,6 +31,7 @@ struct Launch { process: Option, ready: Option, pid: Option, + attach: Option, budget: Budget, } @@ -80,7 +82,8 @@ impl RemoteRuntime { )); } if let (Some(process), Some(ready)) = (&launch.process, &launch.ready) { - if process.alive.load(Ordering::SeqCst) { + // A reattached runtime has no channel to report its exit, so each call rechecks it. + if process.writer.is_some() && process.alive.load(Ordering::SeqCst) { return Ok(ready.clone()); } } @@ -103,6 +106,10 @@ impl RemoteRuntime { async fn relaunch(&self, launch: &mut Launch) -> Result { let options = &self.options; let connection = self.pool.connection(&options.host_id).await?; + if let Some(ready) = self.reattach(&connection.handle, launch).await { + return Ok(ready); + } + launch.attach = None; if let Some(pid) = launch.pid.take() { let stop = protocol::stop_stale_command(pid); let _ = exec(&connection.handle, &stop, None, Duration::from_secs(10)).await; @@ -126,7 +133,12 @@ impl RemoteRuntime { .map_err(lost)?; let (reader, writer) = channel.split(); let writer: Writer = Arc::new(writer); - let frame = frames::launch_frame(&options.root, &options.workspace_key, &options.token); + let frame = frames::launch_frame( + &options.root, + &options.workspace_key, + &options.token, + options.keep_alive, + ); writer.data(frame.as_bytes()).await.map_err(lost)?; let mut stdout = Stdout { reader, @@ -157,7 +169,9 @@ impl RemoteRuntime { } }; launch.pid = stdout.pid(); - health(&self.pool, &options.host_id, port).await?; + launch.attach = health(&self.pool, &options.host_id, port) + .await? + .map(|runtime_id| Attach { port, runtime_id }); let alive = Arc::new(AtomicBool::new(true)); let tasks = vec![ tokio::spawn(heartbeat(writer.clone(), alive.clone())), @@ -169,7 +183,7 @@ impl RemoteRuntime { )), ]; launch.process = Some(Process { - writer, + writer: Some(writer), alive, tasks, }); @@ -184,6 +198,46 @@ impl RemoteRuntime { Ok(ready) } + /// Reuses a runtime that kept running after its exec channel closed: same token and port, so + /// its terminals and tasks survive. Heartbeats and task port forwards need the closed channel. + async fn reattach( + &self, + handle: &russh::client::Handle, + launch: &mut Launch, + ) -> Option { + let (pid, attach) = reattach::candidate(launch.pid, launch.attach.as_ref())?; + let ready = launch.ready.clone()?; + let output = exec( + handle, + &reattach::alive_command(pid), + None, + Duration::from_secs(10), + ) + .await + .ok()?; + let ps_args = String::from_utf8_lossy(&output.stdout); + if !reattach::ours(&ps_args) { + return None; + } + let id = health(&self.pool, &self.options.host_id, attach.port) + .await + .ok()?; + if !reattach::can_reattach(&ps_args, &attach, id.as_deref()) { + return None; + } + launch.process = Some(Process { + writer: None, + alive: Arc::new(AtomicBool::new(true)), + tasks: Vec::new(), + }); + let _ = self.target.send(Some(("127.0.0.1".into(), attach.port))); + (self.events)(Event::Progress { + message: reattach::REATTACHED.into(), + }); + (self.events)(Event::Running); + Some(ready) + } + fn observe(&self, frame: Frame, writer: &Writer) { match frame { Frame::Progress { message } => (self.events)(Event::Progress { message }), @@ -234,20 +288,35 @@ impl RemoteRuntime { }) } - /// Asks the runtime to shut down, then closes its channel and every loopback listener. + /// Asks the runtime to shut down, then closes its channel and every loopback listener. Without + /// a live channel the runtime would wait out its keep-alive lease, so it is stopped by PID. pub async fn stop(&self) { self.stopped.store(true, Ordering::SeqCst); let _ = self.target.send(None); self.forwards.clear().await; - let process = self.launch.lock().await.process.take(); + let (process, pid) = { + let mut launch = self.launch.lock().await; + (launch.process.take(), launch.pid.take()) + }; + let mut shut_down = false; if let Some(process) = process { - let _ = process.writer.data(frames::SHUTDOWN_FRAME.as_bytes()).await; + if let (Some(writer), true) = (&process.writer, process.alive.load(Ordering::SeqCst)) { + shut_down = writer.data(frames::SHUTDOWN_FRAME.as_bytes()).await.is_ok(); + } let deadline = Instant::now() + Duration::from_millis(4500); - while process.alive.load(Ordering::SeqCst) && Instant::now() < deadline { + while shut_down && process.alive.load(Ordering::SeqCst) && Instant::now() < deadline { tokio::time::sleep(Duration::from_millis(50)).await; } process.close(); } + if let (false, Some(pid)) = (shut_down, pid) { + let stop = async { + let connection = self.pool.connection(&self.options.host_id).await?; + let command = protocol::stop_stale_command(pid); + exec(&connection.handle, &command, None, Duration::from_secs(10)).await + }; + let _ = tokio::time::timeout(Duration::from_secs(15), stop).await; + } } pub fn url(&self) -> String { diff --git a/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs b/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs index ed8180c9..5cf13cdd 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_tunnel.rs @@ -70,7 +70,8 @@ async fn forward( } /// The desktop's readiness check: `/api/health` answers protocol 1 through the server's loopback. -pub async fn health(pool: &Pool, host_id: &str, port: u16) -> Result<()> { +/// Returns the runtime's `id` when it reports one. +pub async fn health(pool: &Pool, host_id: &str, port: u16) -> Result> { let check = async { let connection = pool.connection(host_id).await?; let channel = connection @@ -90,20 +91,14 @@ pub async fn health(pool: &Pool, host_id: &str, port: u16) -> Result<()> { let response = tokio::time::timeout(HEALTH_TIMEOUT, check) .await .map_err(|_| Error::new("REMOTE_RUNTIME", "The remote runtime did not answer."))??; - if healthy(&response) { - Ok(()) - } else { - Err(Error::new( - "REMOTE_RUNTIME", - "Remote runtime health check failed", - )) - } + let body = health_body(&response) + .ok_or_else(|| Error::new("REMOTE_RUNTIME", "Remote runtime health check failed"))?; + Ok(body["id"].as_str().map(str::to_string)) } -pub fn healthy(response: &str) -> bool { - let Some((head, body)) = response.split_once("\r\n\r\n") else { - return false; - }; +/// The JSON body of a protocol 1 health response. +pub fn health_body(response: &str) -> Option { + let (head, body) = response.split_once("\r\n\r\n")?; let ok = head .lines() .next() @@ -111,7 +106,7 @@ pub fn healthy(response: &str) -> bool { let body = body.trim(); let body = body.find('{').map_or(body, |start| &body[start..]); let body = body.rfind('}').map_or(body, |end| &body[..=end]); - ok && serde_json::from_str::(body).is_ok_and(|value| { - value["ok"] == serde_json::json!(true) && value["protocol"] == serde_json::json!(1) - }) + let value = serde_json::from_str::(body).ok()?; + (ok && value["ok"] == serde_json::json!(true) && value["protocol"] == serde_json::json!(1)) + .then_some(value) } diff --git a/apps/ios/src/ios.css b/apps/ios/src/ios.css index 1d0d5eaa..37fd77af 100644 --- a/apps/ios/src/ios.css +++ b/apps/ios/src/ios.css @@ -104,3 +104,8 @@ body { background: #0f1115; color: #e6e6e6; -webkit-text-size-adjust: 100%; } & .activity-bar > button.active::before { display: none; } } } +.ios-runtime-sheet > .runtime-page { + height: auto; min-height: 100%; + padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) calc(32px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); +} +.ios-runtime-sheet > .runtime-page > * { margin-inline: auto; } diff --git a/apps/ios/src/main.tsx b/apps/ios/src/main.tsx index f738e320..ddd139fa 100644 --- a/apps/ios/src/main.tsx +++ b/apps/ios/src/main.tsx @@ -7,7 +7,8 @@ import { IosGitClient, native, ssh, type RecentWorkspace, type RemoteRuntimeEven import { configurePanelWindows, currentTheme, themeMode, themeVariables, Workbench } from "@oxbit/workbench"; import { installTextInputPolicy, setLocale } from "@oxbit/ui"; import { StartScreen } from "./start-screen.js"; -import { RuntimeConnection } from "./runtime-connection.js"; +import { RuntimePage } from "@oxbit/feature-runtime"; +import { runtimeConnector } from "./runtime-connector.js"; import { GitSettings } from "./git-settings.js"; import { CloneRepository } from "./clone-repository.js"; import { SshSettings } from "./ssh-settings.js"; @@ -65,7 +66,7 @@ function App() { const [workspaceKey, setWorkspaceKey] = useState(0); const [recents, setRecents] = useState([]); const [sheet, setSheet] = useState(true); - const [connection, setConnection] = useState(false); + const [runtimePage, setRuntimePage] = useState(false); const [gitSettings, setGitSettings] = useState(false); const [cloning, setCloning] = useState(false); const [sshSettings, setSshSettings] = useState(false); @@ -102,6 +103,7 @@ function App() { setWorkspace(undefined); setSheet(true); }); + runtimeConnector.attach(undefined); await closeWorkspace(previous).catch((e) => setError(describe(e))); }, []); @@ -159,12 +161,16 @@ function App() { setWorkspace(next); setWorkspaceKey(key => key + 1); setSheet(false); + setRuntimePage(false); }); + runtimeConnector.attach(next); if (previous) await closeWorkspace(previous); setRecents(await loadRecents()); } catch (e) { const message = describe(e); if (!/cancelled/i.test(message)) setError(message); + if (request.kind === "runtime" || request.kind === "sshRuntime" || (request.kind === "recent" && request.recent.kind === "runtime")) + runtimeConnector.store.fail(message); return message; } finally { opening.current = false; @@ -172,9 +178,19 @@ function App() { } }, [remoteEvent, close]); + useEffect(() => { runtimeConnector.setRecents(recents); }, [recents]); + useEffect(() => { + runtimeConnector.host = { + open, close, + forget: id => forgetRecent(id).then(setRecents), + startSsh: (hostId, path) => setSshConnect({ preset: { hostId, path }, mode: "runtime" }), + manageSsh: () => setSshSettings(true), + }; + }, [open, close]); useEffect(() => { void (async () => { try { + await runtimeConnector.loadSettings(); setRecents(await loadRecents()); const last = await lastWorkspace(); if (last?.kind === "ssh" || last?.kind === "sshRuntime") setSshConnect(sshTarget(last, last.kind === "sshRuntime" ? "runtime" : "files")); @@ -191,7 +207,11 @@ function App() { // iOS suspends sockets in the background, so a remote runtime reconnects on return. const persist = () => { if (document.visibilityState === "hidden") void current.current?.session.persist(); - else resume(); + else { + resume(); + const runtime = current.current?.session.runtime; + if (runtime && !current.current?.remote && !runtime.connected && runtimeConnector.autoReconnect()) runtime.reconnect(); + } }; document.addEventListener("visibilitychange", persist); return () => document.removeEventListener("visibilitychange", persist); @@ -204,7 +224,7 @@ function App() { ["workspace.open", "Open Folder…", () => open({ kind: "pick" })], ["workspace.switch", "Switch Workspace…", () => setSheet(true)], ["workspace.close", "Close Workspace", () => close()], - ["workspace.runtime", "Connect Runtime", () => setConnection(true)], + ["workspace.runtime", "Runtime", () => session.workbench.run("runtime.cloud")], ["git.account", "Git Accounts and Commit Author", () => setGitSettings(true)], ["workspace.clone", "Clone Repository to Device", () => setCloning(true)], ] as const; @@ -245,7 +265,7 @@ function App() { onOpenRecent={(recent) => void open({ kind: "recent", recent })} onForget={(recent) => void forgetRecent(recent.id).then(setRecents, (e) => setError(describe(e)))} onDismiss={workspace ? () => setSheet(false) : undefined} - onConnect={() => setConnection(true)} + onConnect={() => setRuntimePage(true)} onGitSettings={() => setGitSettings(true)} onClone={() => setCloning(true)} onSsh={recent => setSshConnect(sshTarget(recent))} @@ -256,14 +276,13 @@ function App() { return ( {workspace && ( - setSheet(true)} onConnect={() => setConnection(true)} /> + setSheet(true)} + onConnect={() => void workspace.session.workbench.run("runtime.cloud")} /> )} {(sheet || !workspace) &&
{start}
} - {connection && recent.kind === "runtime")?.url} - connect={async (url, code) => { - const error = await open({ kind: "runtime", url, code }); - if (error) throw new Error(error); - }} disconnect={close} onClose={() => setConnection(false)} />} + {runtimePage &&
+ setRuntimePage(false)} /> +
} {gitSettings && setGitSettings(false)} />} {cloning && open({ kind: "documents", directory })} onClose={() => setCloning(false)} />} diff --git a/apps/ios/src/runtime-connection.tsx b/apps/ios/src/runtime-connection.tsx deleted file mode 100644 index 3d19038f..00000000 --- a/apps/ios/src/runtime-connection.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from "react"; -import type { Session } from "@oxbit/app-workbench"; -import { Dialog } from "@oxbit/ui"; - -export function RuntimeConnection({ session, savedUrl, connect, disconnect, onClose }: { - session?: Session; - savedUrl?: string; - connect: (url: string, code?: string) => Promise; - disconnect: () => Promise; - onClose: () => void; -}) { - const runtime = session?.runtime; - const [url, setUrl] = useState(runtime?.url ?? savedUrl ?? ""); - const [code, setCode] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [connected, setConnected] = useState(!!runtime?.connected); - const [trusted, setTrusted] = useState(!!runtime?.session?.trusted); - useEffect(() => { - setConnected(!!runtime?.connected); - setTrusted(!!runtime?.session?.trusted); - const connection = runtime?.subscribe("connection.change", () => setConnected(!!runtime.connected)); - const trust = runtime?.subscribe("workspace.trust", ({ trusted }) => setTrusted(trusted === true)); - return () => { connection?.(); trust?.(); }; - }, [runtime]); - async function perform(operation: () => Promise) { - if (busy) return; - setBusy(true); - setError(""); - try { await operation(); } - catch (error) { setError(error instanceof Error ? error.message : String(error)); } - finally { setBusy(false); } - } - return { if (!busy) onClose(); }} initialFocus="input"> - {connected && runtime ?
-

Connected to {runtime.url}.

-

Workspace tools: {trusted ? "Trusted" : "Restricted"}

-

Trust allows terminals, tasks, Git hooks, and language servers to run code on your computer.

- - {runtime.session?.owner === false &&

Only the workspace owner can change trust.

} - -
:
{ - event.preventDefault(); - void perform(async () => { await connect(url, code); onClose(); }); - }}> -

Connect to a project on your computer to use its files, terminal, Git, and language tools.

-
- Start a runtime on your computer -

Run this in your project folder, then enter the network address and pairing code below:

-
OXBIT_ORIGINS=tauri://localhost oxbit --host 0.0.0.0 --foreground --no-open
-
- -

Use your computer’s network address. Your iPhone and computer must be able to reach each other.

- -

Enter the owner pairing code shown by the runtime. Connecting opens that computer’s workspace; your on-device folders stay available.

-
- - -
-
} - {error &&

{error}

} -
; -} diff --git a/apps/ios/src/runtime-connector.ts b/apps/ios/src/runtime-connector.ts new file mode 100644 index 00000000..f3b90104 --- /dev/null +++ b/apps/ios/src/runtime-connector.ts @@ -0,0 +1,147 @@ +import { AUTO_RECONNECT_SETTING, KEEP_ALIVE_SETTING, RuntimeStatusStore, clientStatus, keepAliveMs, type RuntimeSettingsAccess } from "@oxbit/feature-runtime"; +import type { DiscoveredRuntime, RuntimeConnector, RuntimeTarget, SshHostSummary } from "@oxbit/workbench"; +import { SESSION_SCOPE, discovery, native, ssh, type RecentWorkspace } from "@oxbit/host-ios"; +import type { OpenRequest, OpenWorkspace } from "./workspaces.js"; + +const SETTINGS_KEY = "runtime-settings"; +const runtimeSettings = [KEEP_ALIVE_SETTING, AUTO_RECONNECT_SETTING]; + +/** What the app shell does for the connector; set once the shell mounts. */ +export interface IosRuntimeHost { + /** Resolves to an error message when the workspace did not open. */ + open(request: OpenRequest): Promise; + close(): Promise; + forget(id: string): Promise; + startSsh(hostId: string, path: string): void; + manageSsh(): void; +} + +function targetOf(recent: RecentWorkspace): RuntimeTarget { + return recent.kind === "sshRuntime" + ? { key: recent.id, kind: "ssh", name: recent.name, hostId: recent.hostId, path: recent.remotePath, detail: `Oxbit runtime · ${recent.remotePath}`, lastConnected: recent.lastOpened } + : { key: recent.id, kind: "url", name: recent.name, url: recent.url, runtimeId: recent.runtimeId, detail: recent.url, lastConnected: recent.lastOpened }; +} + +/** One per app launch; the open workspace changes as the user connects and closes runtimes. */ +export class IosRuntimeConnector implements RuntimeConnector { + readonly store = new RuntimeStatusStore(); + readonly startCommand = "oxbit --lan"; + host?: IosRuntimeHost; + private workspace?: OpenWorkspace; + private saved: RecentWorkspace[] = []; + private mirror: Record = {}; + private detach: (() => void)[] = []; + status = this.store.get; + subscribe = this.store.subscribe; + + /** The start screen has no kernel, so runtime settings are mirrored to app storage. */ + async loadSettings() { + this.mirror = await native.storageGet>(SESSION_SCOPE, SETTINGS_KEY).catch(() => null) ?? {}; + } + readonly settings: RuntimeSettingsAccess = { + get: (id: string) => (this.workspace ? this.workspace.session.kernel.configuration.get(id) : this.mirror[id] as T | undefined), + set: (id, value) => { + this.remember(id, value); + if (this.workspace) return this.workspace.session.kernel.configuration.set(id, value as never); + }, + }; + private remember(id: string, value: unknown) { + if (this.mirror[id] === value) return; + this.mirror = { ...this.mirror, [id]: value }; + void native.storageSet(SESSION_SCOPE, SETTINGS_KEY, this.mirror).catch(() => {}); + } + keepAlive() { return keepAliveMs(this.settings.get(KEEP_ALIVE_SETTING)); } + autoReconnect() { return this.settings.get(AUTO_RECONNECT_SETTING) !== false; } + + setRecents(recents: RecentWorkspace[]) { this.saved = recents.filter(item => item.kind === "runtime" || item.kind === "sshRuntime"); } + + attach(workspace?: OpenWorkspace) { + for (const off of this.detach) off(); + this.detach = []; + this.workspace = workspace; + const runtime = workspace?.session.runtime; + if (!workspace) return this.store.replace({ state: "disconnected", error: this.store.get().error }); + const configuration = workspace.session.kernel.configuration; + for (const id of runtimeSettings) + if (id in this.mirror && configuration.get(id) !== this.mirror[id]) void configuration.set(id, this.mirror[id] as never); + const follow = () => { + for (const id of runtimeSettings) this.remember(id, configuration.get(id)); + if (runtime) runtime.autoReconnect = this.autoReconnect(); + }; + follow(); + this.detach.push(configuration.subscribe(follow)); + if (!runtime) return this.store.replace({ state: "disconnected", error: this.store.get().error }); + const remote = workspace.recent.kind === "sshRuntime"; + const sync = () => this.store.replace({ + state: runtime.connected ? "connected" : this.store.get().state === "failed" ? "failed" : "reconnecting", + ...clientStatus(runtime, remote ? "ssh" : "url", workspace.recent.name), targetKey: workspace.recent.id, + ...(remote ? { host: workspace.recent.name.split(":")[0], port: undefined, workspace: workspace.recent.remotePath } : {}), + }); + sync(); + this.detach.push( + runtime.subscribe("connection.change", ({ state }) => state === "disconnected" ? this.store.set({ state: "disconnected" }) : sync()), + runtime.subscribe("workspace.trust", ({ trusted }) => this.store.set({ trusted: trusted === true })), + ); + } + + quickTarget(): RuntimeTarget | undefined { + const current = this.workspace?.recent; + if (current && (current.kind === "runtime" || current.kind === "sshRuntime")) return targetOf(current); + return this.saved[0] && targetOf(this.saved[0]); + } + async recents() { return this.saved.map(targetOf); } + async forget(target: RuntimeTarget) { await this.host?.forget(target.key); } + + private async open(request: OpenRequest, label: string) { + if (!this.host) throw new Error("Oxbit is still starting."); + this.store.replace({ ...this.store.get(), state: "connecting", progress: `Connecting to ${label}…`, error: undefined }); + const error = await this.host.open(request); + if (error) { + this.store.fail(error); + throw new Error(error); + } + } + async connect(target: RuntimeTarget) { + if (target.kind === "ssh") { + if (target.hostId) this.host?.startSsh(target.hostId, target.path ?? "~"); + return; + } + if (!target.url) throw new Error("This runtime has no address. Pair it again."); + await this.open({ kind: "runtime", url: target.url, runtimeId: target.runtimeId }, target.name); + } + pair(url: string, code: string) { + return this.open({ kind: "runtime", url, code }, url); + } + async disconnect() { + if (this.workspace?.session.runtime) await this.host?.close(); + } + async restart() { + const workspace = this.workspace; + if (workspace?.remote) await ssh.runtimeResume(workspace.remote.id); + else workspace?.session.runtime?.reconnect(); + } + async trust(trusted: boolean) { + const session = this.workspace?.session; + if (!session?.runtime) return; + await session.runtime.trust(trusted); + this.store.set({ trusted: !!session.runtime.session?.trusted }); + session.kernel.context.set("trusted", !!session.runtime.session?.trusted); + session.workbench.touch(); + } + discover(listener: (runtimes: DiscoveredRuntime[]) => void) { + return discovery.watch(runtimes => listener(runtimes.map(runtime => ({ + ...runtime, paired: this.saved.some(recent => recent.runtimeId === runtime.runtimeId), + })))); + } + async sshHosts(): Promise { + const hosts = await ssh.hosts(); + return hosts.map(host => ({ + id: host.id, label: host.label, detail: `${host.username}@${host.hostname}${host.port === 22 ? "" : ":" + host.port}`, + lastPath: this.saved.find(recent => recent.hostId === host.id)?.remotePath, + })); + } + async startSsh(hostId: string, path: string) { this.host?.startSsh(hostId, path); } + manageSsh() { this.host?.manageSsh(); } +} + +export const runtimeConnector = new IosRuntimeConnector(); diff --git a/apps/ios/src/runtime.test.ts b/apps/ios/src/runtime.test.ts index 97a3a85a..d33bae31 100644 --- a/apps/ios/src/runtime.test.ts +++ b/apps/ios/src/runtime.test.ts @@ -45,11 +45,24 @@ describe("iOS runtime connection", () => { }); it("stops a failed client so background retries cannot outlive the connection attempt", async () => { - mocks.connect.mockRejectedValue(new Error("Cannot connect to runtime")); - await expect(connectRuntime("http://computer.local:9277")).rejects.toThrow("Cannot connect"); + mocks.connect.mockRejectedValue(new Error("Pairing expired")); + await expect(connectRuntime("http://computer.local:9277")).rejects.toThrow("Pairing expired"); expect(mocks.dispose).toHaveBeenCalledOnce(); }); + it("explains a refused connection from the native health check", async () => { + mocks.connect.mockRejectedValue(new Error("Cannot connect to runtime")); + mocks.credentials.mockImplementation(async ({ operation }) => operation === "health" + ? { runtime: { id: "abc12345", name: "studio", version: "0.4.2", startedAt: 1 } } : { token: "native-test-token" }); + await expect(connectRuntime("http://computer.local:9277", undefined, "abc12345")).rejects.toThrow("studio is running but did not accept this device"); + expect(mocks.credentials).toHaveBeenCalledWith({ operation: "get", url: "http://computer.local:9277", code: undefined, runtimeId: "abc12345" }); + mocks.credentials.mockImplementation(async ({ operation }) => { + if (operation === "health") throw new Error("The runtime is unreachable: Could not connect to the server."); + return { token: "native-test-token" }; + }); + await expect(connectRuntime("http://computer.local:9277")).rejects.toThrow(/No runtime answered[^]*Could not connect to the server/); + }); + it("keeps drafts scoped to the remote workspace, with a filesystem-safe identity", async () => { const remote = (key: string, port = 9277) => ({ url: `http://computer.local:${port}`, workspaceId: "default", session: { workspaceKey: key } }) as RuntimeClient; const first = await runtimeScope(remote("project-one")); diff --git a/apps/ios/src/runtime.ts b/apps/ios/src/runtime.ts index e4272df6..504b279d 100644 --- a/apps/ios/src/runtime.ts +++ b/apps/ios/src/runtime.ts @@ -1,5 +1,6 @@ import { RuntimeClient } from "@oxbit/app-workbench"; import { native } from "@oxbit/host-ios"; +import { RUNTIME_OUTPUT_SEPARATOR } from "@oxbit/protocol"; export function runtimeUrl(value: string): string { let url: URL; @@ -10,10 +11,10 @@ export function runtimeUrl(value: string): string { return url.origin; } -export async function connectRuntime(address: string, code?: string): Promise { +export async function connectRuntime(address: string, code?: string, runtimeId?: string): Promise { const url = runtimeUrl(address); const credential = await native.runtimeCredentials({ - operation: code?.trim() ? "pair" : "get", url, code: code?.trim(), + operation: code?.trim() ? "pair" : "get", url, code: code?.trim(), runtimeId, }); if (!credential.token) throw new Error("Enter the pairing code shown by the runtime on your computer."); const runtime = new RuntimeClient(url, "default", { token: credential.token, persistToken: false }); @@ -22,7 +23,20 @@ export async function connectRuntime(address: string, code?: string): Promise 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.

: <> - - {error && ( -

- {error} -

- )} -
- - -
- - )} - - ); -} createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/src/runtime-connector.ts b/apps/web/src/runtime-connector.ts new file mode 100644 index 00000000..754d5479 --- /dev/null +++ b/apps/web/src/runtime-connector.ts @@ -0,0 +1,139 @@ +import { translate as tr } from "@oxbit/ui"; +import type { Persistence } from "@oxbit/sdk"; +import { RuntimeClient } from "@oxbit/host-runtime"; +import { AUTO_RECONNECT_SETTING, RuntimeStatusStore, clientStatus } from "@oxbit/feature-runtime"; +import type { RuntimeConnector, RuntimeTarget } from "@oxbit/workbench"; +import type { Session } from "@oxbit/app-workbench"; + +const SAVED_KEY = "runtimes"; +const LIMIT = 12; + +export interface WebRuntimeHost { + persistence: Persistence; + /** Boots a workbench session on the client and makes it current. */ + open(runtime: RuntimeClient): Promise; +} + +export const targetKey = (url: string, runtimeId?: string) => runtimeId ? "url:" + runtimeId : "url:" + url; + +/** Explains a failed connection with what the address actually answered. */ +export async function describeFailure(url: string, error: unknown) { + const text = error instanceof Error ? error.message : String(error); + if (!/cannot connect|connection closed|timed out|failed to fetch/i.test(text)) return text; + try { + const health = await fetch(url + "/api/health", { signal: AbortSignal.timeout(4000) }); + if (!health.ok) return tr("{0} answered HTTP {1}; it is not an Oxbit runtime.", { 0: url, 1: health.status }); + return tr("The runtime at {0} is running but refused this browser. Pair it again with its pairing code.", { 0: url }); + } catch { + // Another origin's health reply is unreadable without CORS, but an opaque reply still proves something listens. + const reachable = await fetch(url + "/api/health", { mode: "no-cors", signal: AbortSignal.timeout(4000) }).then(() => true, () => false); + return reachable + ? tr("{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.", { 0: url }) + : tr("No runtime answered at {0}. Start one with oxbit and check the address.", { 0: url }); + } +} + +/** One per page load; workbench sessions come and go as runtimes are connected. */ +export class WebRuntimeConnector implements RuntimeConnector { + readonly store = new RuntimeStatusStore(); + readonly startCommand = "oxbit --no-open"; + private session?: Session; + private detach: (() => void)[] = []; + constructor(private readonly host: WebRuntimeHost) {} + status = this.store.get; + subscribe = this.store.subscribe; + + /** Follows the runtime of the session now on screen. */ + attach(session: Session) { + for (const off of this.detach) off(); + this.session = session; + const runtime = session.runtime; + if (!runtime) { + this.detach = []; + this.store.replace({ state: "disconnected", error: this.store.get().error }); + return; + } + const sync = () => this.store.replace({ + state: runtime.connected ? "connected" : this.store.get().state === "failed" ? "failed" : "reconnecting", + ...clientStatus(runtime, "url"), targetKey: targetKey(runtime.url, runtime.identity?.id), + }); + const applySetting = () => { runtime.autoReconnect = session.kernel.configuration.get(AUTO_RECONNECT_SETTING) !== false; }; + applySetting(); + sync(); + this.detach = [ + runtime.subscribe("connection.change", ({ state }) => state === "disconnected" ? this.store.set({ state: "disconnected" }) : sync()), + runtime.subscribe("workspace.trust", ({ trusted }) => this.store.set({ trusted: trusted === true })), + session.kernel.configuration.subscribe(applySetting), + ]; + } + + quickTarget(): RuntimeTarget | undefined { + const runtime = this.session?.runtime; + if (runtime) return { key: targetKey(runtime.url, runtime.identity?.id), kind: "url", name: runtime.identity?.name ?? new URL(runtime.url).host, url: runtime.url, runtimeId: runtime.identity?.id }; + return this.saved[0]; + } + private saved: RuntimeTarget[] = []; + async load() { + const stored = await this.host.persistence.get(SAVED_KEY); + const last = await this.host.persistence.get<{ url?: string; runtimeId?: string }>("last-host"); + this.saved = Array.isArray(stored) ? stored : []; + if (!this.saved.length && last?.url) + this.saved = [{ key: targetKey(last.url, last.runtimeId), kind: "url", name: new URL(last.url).host, url: last.url, runtimeId: last.runtimeId }]; + return this.saved; + } + recents = () => this.load(); + async forget(target: RuntimeTarget) { + this.saved = (await this.load()).filter(item => item.key !== target.key && item.url !== target.url); + await this.host.persistence.set(SAVED_KEY, this.saved); + } + private async remember(runtime: RuntimeClient) { + const id = runtime.identity?.id; + const entry: RuntimeTarget = { + key: targetKey(runtime.url, id), kind: "url", runtimeId: id, url: runtime.url, lastConnected: Date.now(), + name: runtime.identity?.name ?? new URL(runtime.url).host, detail: runtime.session?.workspaceName ? `${runtime.session.workspaceName} · ${runtime.url}` : runtime.url, + }; + this.saved = [entry, ...(await this.load()).filter(item => item.key !== entry.key && item.url !== entry.url && (!id || item.runtimeId !== id))].slice(0, LIMIT); + await this.host.persistence.set(SAVED_KEY, this.saved); + await this.host.persistence.set("last-host", { url: runtime.url, runtimeId: id }); + } + + private async establish(url: string, label: string, start: (runtime: RuntimeClient) => Promise, runtimeId?: string) { + this.store.replace({ ...this.store.get(), state: "connecting", progress: tr("Connecting to {0}…", { 0: label }), error: undefined }); + const runtime = new RuntimeClient(url.replace(/\/$/, ""), "default", { runtimeId }); + try { + await start(runtime); + this.store.progress(tr("Opening the workspace…")); + await this.host.open(runtime); + await this.remember(runtime); + } catch (error) { + runtime.dispose(); + this.store.fail(await describeFailure(runtime.url, error)); + throw error; + } + } + connect(target: RuntimeTarget) { + if (!target.url) return Promise.reject(new Error(tr("This runtime has no address. Pair it again."))); + return this.establish(target.url, target.name, runtime => runtime.connect(), target.runtimeId); + } + pair(url: string, code: string) { + let address: URL; + try { address = new URL(url); } catch { return Promise.reject(new Error(tr("Enter the runtime's http:// or https:// address."))); } + return this.establish(address.origin, address.host, async runtime => { + await runtime.pair(code); + await runtime.connect(); + }); + } + async disconnect() { + this.session?.runtime?.disconnect(); + this.store.set({ state: "disconnected", progress: undefined }); + this.session?.workbench.touch(); + } + async trust(trusted: boolean) { + const runtime = this.session?.runtime; + if (!runtime) return; + await runtime.trust(trusted); + this.store.set({ trusted: !!runtime.session?.trusted }); + this.session!.kernel.context.set("trusted", !!runtime.session?.trusted); + this.session!.workbench.touch(); + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 542b0531..4b125746 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../packages/features/settings" }, + { + "path": "../../packages/features/runtime" + }, { "path": "../../packages/features/extensions" }, diff --git a/bun.lock b/bun.lock index c26de945..f0fa0e80 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "oxbit", "devDependencies": { "@playwright/test": "1.62.1", - "@types/markdown-it": "^10.0.3", + "@types/markdown-it": "^14.2.0", "@types/node": "^24.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -26,17 +26,18 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.42.0", "vite": "^7.1.5", - "vitest": "^3.2.4", + "vitest": "^4.1.11", }, }, "apps/desktop": { "name": "@oxbit/desktop", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", "@oxbit/host-browser": "workspace:*", "@oxbit/host-desktop": "workspace:*", "@oxbit/host-runtime": "workspace:*", + "@oxbit/protocol": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/themes": "workspace:*", "@oxbit/ui": "workspace:*", @@ -52,11 +53,13 @@ }, "apps/ios": { "name": "@oxbit/ios", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/host-browser": "workspace:*", "@oxbit/host-ios": "workspace:*", + "@oxbit/protocol": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/themes": "workspace:*", "@oxbit/ui": "workspace:*", @@ -71,7 +74,7 @@ }, "apps/runtime": { "name": "@oxbit/runtime", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/core": "workspace:*", "@oxbit/protocol": "workspace:*", @@ -81,7 +84,7 @@ "ignore": "7.0.5", "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", - "tar": "7.5.9", + "tar": "7.5.22", "typescript": "^5.9.3", "typescript-language-server": "^5.0.0", "vscode-jsonrpc": "^8.2.1", @@ -109,6 +112,7 @@ "@oxbit/feature-git": "workspace:*", "@oxbit/feature-language": "workspace:*", "@oxbit/feature-previews": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-search": "workspace:*", "@oxbit/feature-settings": "workspace:*", "@oxbit/feature-tasks": "workspace:*", @@ -152,6 +156,7 @@ "@oxbit/feature-keymaps": "workspace:*", "@oxbit/feature-language": "workspace:*", "@oxbit/feature-previews": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-search": "workspace:*", "@oxbit/feature-settings": "workspace:*", "@oxbit/feature-tasks": "workspace:*", @@ -191,8 +196,8 @@ "@oxbit/ui": "workspace:*", "@oxbit/workbench": "workspace:*", "diff": "8.0.4", - "dompurify": "^3.2.7", - "markdown-it": "^10.0.0", + "dompurify": "^3.4.16", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", }, @@ -324,7 +329,7 @@ "@oxbit/documents": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/ui": "workspace:*", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", "vscode-json-languageservice": "5.6.1", @@ -339,9 +344,21 @@ "@oxbit/documents": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/ui": "workspace:*", - "dompurify": "^3.2.7", + "dompurify": "^3.4.16", "htmlparser2": "^12.0.0", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", + "react": "19.1.0", + "react-dom": "19.1.0", + }, + }, + "packages/features/runtime": { + "name": "@oxbit/feature-runtime", + "version": "1.0.0", + "dependencies": { + "@oxbit/protocol": "workspace:*", + "@oxbit/sdk": "workspace:*", + "@oxbit/ui": "workspace:*", + "@oxbit/workbench": "workspace:*", "react": "19.1.0", "react-dom": "19.1.0", }, @@ -430,9 +447,12 @@ "version": "1.0.0", "dependencies": { "@oxbit/app-workbench": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/host-runtime": "workspace:*", "@oxbit/icon-themes": "workspace:*", + "@oxbit/protocol": "workspace:*", "@oxbit/sdk": "workspace:*", + "@oxbit/workbench": "workspace:*", "@tauri-apps/api": "2.11.1", }, }, @@ -528,6 +548,12 @@ ], "overrides": { "@xterm/addon-ligatures": "0.11.0-beta.213", + "basic-ftp": "6.2.2", + "fast-uri": "3.1.8", + "ip-address": "10.7.1", + "serialize-javascript": "7.0.5", + "smol-toml": "1.9.0", + "source-map-js": "1.2.2", }, "packages": { "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.3.2" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], @@ -806,6 +832,8 @@ "@oxbit/feature-previews": ["@oxbit/feature-previews@workspace:packages/features/previews"], + "@oxbit/feature-runtime": ["@oxbit/feature-runtime@workspace:packages/features/runtime"], + "@oxbit/feature-search": ["@oxbit/feature-search@workspace:packages/features/search"], "@oxbit/feature-settings": ["@oxbit/feature-settings@workspace:packages/features/settings"], @@ -908,6 +936,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], @@ -954,8 +984,6 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/highlight.js": ["@types/highlight.js@9.12.4", "", {}, "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww=="], - "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "2.0.6" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], @@ -968,7 +996,7 @@ "@types/lodash": ["@types/lodash@4.17.25", "", {}, "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ=="], - "@types/markdown-it": ["@types/markdown-it@10.0.3", "", { "dependencies": { "@types/highlight.js": "9.12.4", "@types/linkify-it": "5.0.0", "@types/mdurl": "2.0.0", "highlight.js": "9.18.5" } }, "sha512-daHJk22isOUvNssVGF2zDnnSyxHhFYhtjeX4oQaKD6QzL3ZR1QSgiD1g+Q6/WSWYVogNXYDXODtbgW/WiFCtyw=="], + "@types/markdown-it": ["@types/markdown-it@14.2.0", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ=="], "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], @@ -1022,19 +1050,19 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/plugin-transform-react-jsx-self": "7.29.7", "@babel/plugin-transform-react-jsx-source": "7.29.7", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "7.20.5", "react-refresh": "0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], - "@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "5.2.3", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "5.3.3", "tinyrainbow": "2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - "@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "3.0.3", "magic-string": "0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "3.1.1" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - "@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "2.0.3", "strip-literal": "3.1.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "0.30.21", "pathe": "1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], - "@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "4.0.6" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], - "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "3.2.1", "tinyrainbow": "2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.1" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], @@ -1126,7 +1154,7 @@ "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "10.5.0", "graceful-fs": "4.2.11", "is-stream": "2.0.1", "lazystream": "1.0.1", "lodash": "4.18.1", "normalize-path": "3.0.0", "readable-stream": "4.7.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "1.0.3" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -1156,13 +1184,13 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], - "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + "basic-ftp": ["basic-ftp@6.2.2", "", {}, "sha512-LIkJcf/F6bTTfvE3k2YcTxrJsLKKVXiEpHZYkQP6JoqTb7khng7c13ckRTDQbqRh5IkehVH3F00sSlAa2F6DwA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1176,22 +1204,18 @@ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], - "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "2.0.1", "check-error": "2.1.3", "deep-eql": "5.0.2", "loupe": "3.2.1", "pathval": "2.0.1" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "2.1.0", "dom-serializer": "2.0.0", "domhandler": "5.0.3", "domutils": "3.2.2", "encoding-sniffer": "0.2.1", "htmlparser2": "10.1.0", "parse5": "7.3.0", "parse5-htmlparser2-tree-adapter": "7.1.0", "parse5-parser-stream": "7.1.2", "undici": "7.29.1", "whatwg-mimetype": "4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "1.0.0", "css-select": "5.2.2", "css-what": "6.2.2", "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -1270,7 +1294,7 @@ "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], - "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], + "dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="], "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "3.1.1", "domelementtype": "3.0.0", "domhandler": "6.0.1" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], @@ -1294,11 +1318,11 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], @@ -1356,7 +1380,7 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "1.6.2", "xml-naming": "0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], @@ -1420,8 +1444,6 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], - "highlight.js": ["highlight.js@9.18.5", "", {}, "sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA=="], - "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "10.4.3" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], "html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="], @@ -1458,7 +1480,7 @@ "inquirer": ["inquirer@12.11.1", "", { "dependencies": { "@inquirer/ansi": "1.0.2", "@inquirer/core": "10.3.2", "@inquirer/prompts": "7.10.1", "@inquirer/type": "3.0.10", "mute-stream": "2.0.0", "run-async": "4.0.6", "rxjs": "7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw=="], - "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -1560,7 +1582,7 @@ "lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="], - "linkify-it": ["linkify-it@2.2.0", "", { "dependencies": { "uc.micro": "1.0.6" } }, "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "locate-app": ["locate-app@2.5.0", "", { "dependencies": { "@promptbook/utils": "0.69.5", "type-fest": "4.26.0", "userhome": "1.0.1" } }, "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q=="], @@ -1586,17 +1608,15 @@ "loglevel-plugin-prefix": ["loglevel-plugin-prefix@0.8.4", "", {}, "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g=="], - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.6.0" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "markdown-it": ["markdown-it@10.0.0", "", { "dependencies": { "argparse": "1.0.10", "entities": "2.0.3", "linkify-it": "2.2.0", "mdurl": "1.0.1", "uc.micro": "1.0.6" }, "bin": { "markdown-it": "bin/markdown-it.js" } }, "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg=="], + "markdown-it": ["markdown-it@14.3.2", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q=="], - "mdurl": ["mdurl@1.0.1", "", {}, "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="], + "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.18" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.21" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1636,6 +1656,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "opentype.js": ["opentype.js@0.8.0", "", { "dependencies": { "tiny-inflate": "1.0.3" }, "bin": { "ot": "./bin/ot" } }, "sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg=="], @@ -1676,8 +1698,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1712,9 +1732,9 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], - "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], @@ -1772,7 +1792,7 @@ "serialize-error": ["serialize-error@12.0.0", "", { "dependencies": { "type-fest": "4.41.0" } }, "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw=="], - "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="], "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], @@ -1788,7 +1808,7 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "10.7.0", "smart-buffer": "4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], @@ -1796,7 +1816,7 @@ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "spacetrim": ["spacetrim@0.11.59", "", {}, "sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg=="], @@ -1810,13 +1830,11 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], "stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="], @@ -1836,15 +1854,13 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], - "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "4.0.1", "chownr": "3.0.0", "minipass": "7.1.3", "minizlib": "3.1.0", "yallist": "5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], "tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "3.0.4", "tar-stream": "3.2.1" }, "optionalDependencies": { "bare-fs": "4.8.1", "bare-path": "3.1.2" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], @@ -1858,15 +1874,11 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.7" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - - "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "tinyspy": ["tinyspy@4.0.6", "", {}, "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1886,7 +1898,7 @@ "typescript-language-server": ["typescript-language-server@5.3.0", "", { "dependencies": { "vscode-jsonrpc": "5.0.1", "vscode-languageserver-protocol": "3.17.5" }, "bin": { "typescript-language-server": "lib/cli.mjs" } }, "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ=="], - "uc.micro": ["uc.micro@1.0.6", "", {}, "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], @@ -1914,9 +1926,7 @@ "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "0.28.2", "fdir": "6.5.0", "picomatch": "4.0.7", "postcss": "8.5.28", "rollup": "4.63.1", "tinyglobby": "0.2.17" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.3", "es-module-lexer": "1.7.0", "pathe": "2.0.3", "vite": "7.3.6" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - - "vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "5.2.3", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "5.3.3", "debug": "4.4.3", "expect-type": "1.4.0", "magic-string": "0.30.21", "pathe": "2.0.3", "picomatch": "4.0.7", "std-env": "3.10.0", "tinybench": "2.9.0", "tinyexec": "0.3.2", "tinyglobby": "0.2.17", "tinypool": "1.1.1", "tinyrainbow": "2.0.0", "vite": "7.3.6", "vite-node": "3.2.4", "why-is-node-running": "2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], "vscode-json-languageservice": ["vscode-json-languageservice@5.6.1", "", { "dependencies": { "@vscode/l10n": "0.0.18", "jsonc-parser": "3.3.1", "vscode-languageserver-textdocument": "1.0.12", "vscode-languageserver-types": "3.18.3", "vscode-uri": "3.2.0" } }, "sha512-IQIURBF2VMKBdWcMunbHSI3G2WmJ9H7613E1hRxIXX7YsAPSdBxnEiIUrTnsSW/3fk+QW1kfsvSigqgAFYIYtg=="], @@ -2016,7 +2026,7 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "5.0.9" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "5.0.12" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -2086,9 +2096,9 @@ "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2102,8 +2112,6 @@ "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "jszip/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "1.0.3", "inherits": "2.0.4", "isarray": "1.0.0", "process-nextick-args": "2.0.1", "safe-buffer": "5.1.2", "string_decoder": "1.1.1", "util-deprecate": "1.0.2" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2122,7 +2130,7 @@ "mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "1.0.0", "inflight": "1.0.6", "inherits": "2.0.4", "minimatch": "5.1.9", "once": "1.4.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], - "mocha/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "mocha/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "mocha/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "7.0.4", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "20.2.9" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], @@ -2144,13 +2152,11 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "randombytes/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "7.0.2", "semver": "7.8.5", "validate-npm-package-license": "3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "read-pkg-up/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "7.2.0", "path-exists": "5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "resq/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -2164,11 +2170,9 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "typescript-language-server/vscode-jsonrpc": ["vscode-jsonrpc@5.0.1", "", {}, "sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A=="], - "vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + "vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -2206,7 +2210,7 @@ "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], @@ -2230,8 +2234,6 @@ "cheerio/dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], - "cheerio/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "cheerio/domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], "cheerio/domutils/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -2252,13 +2254,9 @@ "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "expect-webdriverio/@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "3.1.1" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - - "expect-webdriverio/@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.1" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2278,7 +2276,7 @@ "mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.2" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "mocha/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], @@ -2290,7 +2288,7 @@ "read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "10.4.3" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2304,14 +2302,6 @@ "@wdio/tauri-service/@wdio/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "cheerio-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - - "css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - - "expect-webdriverio/@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - - "expect-webdriverio/@vitest/snapshot/@vitest/utils/tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - "mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "mocha/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/docs/INDEX.md b/docs/INDEX.md index 720afe19..f90bcfa2 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,16 +1,20 @@ # 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. | -| [iOS setup](ios.md) | Building the iOS app or connecting to a computer runtime. | +| [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; the remote runtime lease and `runtime.keepAlive`; 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, connecting to a computer runtime, Bonjour discovery of `oxbit --lan` runtimes, and keep-alive for SSH-started runtimes; NWBrowser, NSBonjourServices, RFC 6762/6763 and Tauri mobile plugin references. | | [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. | | [iOS language servers](ios-language-servers.md) | Changing device-local LSPs, JavaScriptCore, or the native filesystem bridge; includes Apple and Tauri API references. | | [iOS SSH and SFTP workspaces](ios-ssh.md) | Changing SSH keys, saved hosts, known-hosts trust, the russh session pool, the SFTP FileSystem, transfers, Git over SSH remotes, or the remote runtime started from iOS; includes russh/aws-lc build notes and russh, Keychain, SFTP, git2 transport, and pack protocol references. | | [iOS source control integration](ios-source-control.md) | On-device repositories, GitHub and Gitea credentials, OpenPGP commit signing, SSH remote key choice, native transport, and official API references | | [Source Control](source-control.md) | Git workflows in runtime workspaces | +| [Dependency audit](security.md#dependency-audit) | Running `bun audit`, the managed language-server `npm audit`, and the Cargo OSV query; fixed versions and accepted advisories with reachability; OSV batch API reference. | +| [Runtime, trust and recovery](runtime.md) | Starting runtimes, the random port, runtime identity, `oxbit --lan` and Bonjour advertising, keep-alive, and the Runtime page and its per-app connectors; RFC 6762 and RFC 6763 references for the mDNS responder. | +| [JSON settings](settings.md) | Settings files, precedence, the schema, and the `runtime.keepAlive` and `runtime.autoReconnect` settings. | 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/ios.md b/docs/ios.md index 882fd2de..bac98697 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -1,5 +1,8 @@ # Oxbit for iOS and iPadOS +Created: 2026-09-09 +Last updated: 2026-10-09 + Oxbit runs on iPhone and iPad as a Tauri 2 app in `apps/ios`. It shares the React workbench with the browser and desktop apps. It edits the app's own Documents folder, which the Files app shows under **On My iPhone** or **On My iPad › Oxbit**, and folders chosen from the Files app. Connecting to a runtime opens a project on your computer with its files, terminals, tasks, Git, and language servers. [Source Control on device folders](ios-source-control.md) supports Git through libgit2. **Clone Repository…** opens a cloned repository, and **Git Accounts and Commit Author**, in the Source Control toolbar and on the workspace screen, stores the default author and any number of GitHub and Gitea accounts in Keychain. Each server has one default account, and each repository can choose its own account. @@ -72,14 +75,32 @@ On touch devices a press held on a tab, explorer row, or panel opens the context Start the CLI runtime from your project folder on the computer: ```sh -OXBIT_ORIGINS=tauri://localhost oxbit --host 0.0.0.0 --foreground --no-open +oxbit --lan ``` -The origin setting also supports older CLI runtimes. In the iOS app, tap the connection button in the header or **Connect Runtime…** on the workspace screen. Enter the computer's network address, for example `http://192.168.1.10:9277`, and the owner pairing code printed by the runtime. Both devices need network access to that address. Allow Local Network access when iOS asks. +`--lan` serves on every network interface, allows the iOS app's origin, and advertises the runtime over Bonjour as `_oxbit._tcp` with TXT keys `id`, `name`, `version`, and `port`. The runtime prints its owner pairing code. + +In the iOS app, open the Runtime page from the connection button in the header or **Connect Runtime…** on the workspace screen. Allow Local Network access when iOS asks. The page lists runtimes found on the network. Tap one and enter the pairing code once. Both devices need to be on the same network. A runtime on another network can still be entered by address, for example `http://192.168.1.10:9277`. + +Discovery uses `NWBrowser` in the `oxbit-files` plugin (`RuntimeDiscovery.swift`). For each service it opens a short-lived connection to read the resolved address, IPv4 first, and drops services without a TXT `id`. The WebView polls the `runtime_discovery` command every 2 seconds; browsing stops when the page closes or 2 minutes after the last poll. `Info.ios.plist` declares `_oxbit._tcp` under `NSBonjourServices`, which iOS requires before an app can browse a service type. Connecting opens the computer's workspace. Choose **Trust workspace tools** to permit code execution there. Device folders remain separate and available from the workspace screen. Saved runtime connections reconnect without reentering the pairing code; removing their last recent workspace deletes the saved credential. Disconnect closes the workspace and preserves the credential for next time. -Pairing uses native URLSession, with redirects refused, and stores the token in the device Keychain. The WebView receives the token only in memory and authenticates its WebSocket using the existing runtime protocol. Plain HTTP is allowed for local and private network addresses; public endpoints should use HTTPS. +Pairing uses native URLSession, with redirects refused, and stores the token in the device Keychain under the runtime's `id` from `/api/pair`, so a changed port or address keeps the saved credential and the recent workspace. Tokens saved by earlier versions under the runtime URL move to the `id` the first time they are read. The native `health` operation reads `/api/health`, which the WebView cannot fetch across origins, to identify a runtime before pairing. The WebView receives the token only in memory and authenticates its WebSocket using the existing runtime protocol. Plain HTTP is allowed for local and private network addresses; public endpoints should use HTTPS. + +### Keep SSH-started runtimes running + +A runtime started over [SSH](ios-ssh.md) receives a keep-alive lease in its launch frame. It keeps running after the SSH connection closes, for example while iOS suspends the app, until no WebSocket client or heartbeat has reached it for the lease. The `runtime.keepAlive` setting chooses the lease in milliseconds; 0 keeps the runtime running until it is stopped, and the default is 75000. + +When the app returns, Oxbit checks that the recorded process still runs the Oxbit runtime and that `/api/health` reports the same runtime `id` as at launch. If both hold, it reopens the tunnel to that runtime with the same token, and terminals and tasks continue. The workspace reports "Reconnected to the running runtime." If either check fails, Oxbit stops the old process and launches a new runtime. A reattached runtime has no launch channel, so it receives no heartbeats and cannot forward new task ports until it is relaunched. Closing the workspace stops a reattached runtime by process ID. + +### References + +- Apple, [NWBrowser](https://developer.apple.com/documentation/network/nwbrowser) +- Apple, [NSBonjourServices](https://developer.apple.com/documentation/bundleresources/information-property-list/nsbonjourservices) +- [RFC 6762: Multicast DNS](https://www.rfc-editor.org/rfc/rfc6762) +- [RFC 6763: DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763) +- Tauri v2, [Mobile plugin development](https://v2.tauri.app/develop/plugins/develop-mobile/) ## Vendored crates @@ -145,5 +166,4 @@ upload or distribute a build. ## Not implemented -- SSH tunnel to a remote runtime: a Rust port of the local half of `apps/runtime/src/ssh.ts`. The `russh` session pool from [SSH and SFTP workspaces](ios-ssh.md) can carry its exec and direct-tcpip channels. - Touch drag and drop for editor tabs and explorer rows. 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..63750638 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. @@ -18,7 +18,7 @@ The headless process accepts its workspace and session credential through SSH st Runtime state is stored separately under `~/.oxbit/remote/workspaces/`. Desktop drafts remain in the local application's normal project recovery storage. Workspace trust is still required before terminals, tasks, Git commands, extensions, or language servers execute. SSH authentication does not automatically grant workspace trust. Each connection launches a supervised runtime. A second SSH session for the same canonical remote folder is rejected while its first runtime is alive, protecting the persisted workspace state. Close the first session before opening it elsewhere. A stopped owner's lock is recovered on the next connection. -Closing a project shuts down the remote runtime and its tools and closes the SSH tunnel. Loss of the parent channel also shuts it down. A 10-second heartbeat with a 75-second remote lease bounds orphaned process lifetime if a network failure prevents SSH EOF from arriving. The connector never forwards remote process IDs to the local native supervisor. +Closing a project shuts down the remote runtime and its tools and closes the SSH tunnel. When the SSH channel drops instead, the remote runtime keeps running until its lease expires. The launch frame's `keepAlive` sets the lease in milliseconds: absent means 75000, and `0` runs until stopped. A 10-second heartbeat refreshes the lease, and a connected authenticated WebSocket client holds it; the countdown starts once both stop. A launch for the same folder with the same workspace key sends SIGTERM to the previous runtime, waits up to 5 seconds for it to exit, and takes its lock, so a reconnect replaces its own orphan. A launch with a different workspace key still reports that the folder is already open. The connector never forwards remote process IDs to the local native supervisor. After a connection failure, choose **Reconnect over SSH**. Drafts are retained, saved files stay on the remote disk, and a cached runtime is reused. Runtime restarts end old terminal/task sessions; commands with an uncertain outcome are not automatically replayed. Moving a project between windows rotates its session token through the private SSH channel. @@ -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/docs/runtime.md b/docs/runtime.md index d3c37e40..6df2cd55 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -1,10 +1,13 @@ # Runtime, trust and recovery +Created: 2026-09-06 +Last updated: 2026-10-09 + The file explorer loads the workspace root and the immediate children of expanded folders. Collapsed branches are not traversed, including during refresh; opening or revealing a file expands its ancestors. Listings are cached until a relevant filesystem change, explicit refresh, or collapse. There is no workspace-wide 20,000-entry cutoff. Quick Open searches unopened folders only when a query is entered, cancels superseded searches, and skips `.git` and `node_modules`; workspace export enumerates all folders when explicitly requested. On macOS, dependency installation and the runtime's `build`, `dev`, and `start` scripts automatically repair missing execute permissions on `node-pty`'s `spawn-helper`. The build and launch checks also cover installs that skipped lifecycle scripts. Run `bun run repair:pty` to repair an existing installation manually if terminals report `posix_spawnp failed.` The repair does nothing on other operating systems and leaves already executable helpers unchanged. -`oxbit [path]` starts a runtime for a workspace and opens the paired editor, `oxbit --stop` ends it, and `oxbit --status` reports it. See [the README](../README.md#quick-start) for the global install. `bun run start` with `OXBIT_WORKSPACE=/absolute/workspace` serves the same runtime in the foreground under Node 24. The runtime serves the production browser app at `http://127.0.0.1:9277`, or a free port when that one is taken. The launch URL carries `#pair=` and, for a named file, `&open=`; both are consumed once and stripped from the address bar. Without the fragment the app starts in the browser sample workspace, and the printed owner pairing code can be entered in the connection dialog. `OXBIT_WATCH_POLLING=1` selects filesystem polling; watcher exhaustion (EMFILE/ENOSPC) switches to polling automatically before accepting requests. `PORT`, `HOST`, `OXBIT_DATA_DIR`, `OXBIT_PAIRING_CODE`, `OXBIT_WEB_ROOT` and comma-separated `OXBIT_ORIGINS` configure hosting; `--port` and `--host` override the first two. `OXBIT_PAIRING_ATTEMPTS_PER_MINUTE` sets the pairing attempts accepted per client address each minute (default 10); the browser test server raises it because every test pairs again. Each detached runtime records its pid, port and pairing code in `/daemon.json` at mode 0600 and appends output to `/daemon.log`; reuse needs both a live pid and a healthy reply, so a stale record cannot hand you an unrelated process. Vite development access requires `OXBIT_ORIGINS=http://localhost:9279,http://127.0.0.1:9279`. Bind to loopback by default; use a TLS reverse proxy when exposing the runtime to a network. +`oxbit [path]` starts a runtime for a workspace and opens the paired editor, `oxbit --stop` ends it, and `oxbit --status` reports it. See [the README](../README.md#quick-start) for the global install. `bun run start` with `OXBIT_WORKSPACE=/absolute/workspace` serves the same runtime in the foreground under Node 24. The runtime serves the production browser app on `127.0.0.1` at a port the operating system assigns; `--port` or `PORT` fixes it, and `bun run dev` uses 9277 for the web dev proxy. The launch URL carries `#pair=` and, for a named file, `&open=`; both are consumed once and stripped from the address bar. Without the fragment the app starts in the browser sample workspace, and the printed owner pairing code can be entered on the Runtime page. `OXBIT_WATCH_POLLING=1` selects filesystem polling; the runtime accepts requests before the watcher's first scan finishes, watcher exhaustion (EMFILE/ENOSPC) switches to polling, and other watcher failures reach watching clients as `fs.watchError` and stderr. `PORT`, `HOST`, `OXBIT_DATA_DIR`, `OXBIT_PAIRING_CODE`, `OXBIT_WEB_ROOT` and comma-separated `OXBIT_ORIGINS` configure hosting; `--port` and `--host` override the first two. `OXBIT_PAIRING_ATTEMPTS_PER_MINUTE` sets the pairing attempts accepted per client address each minute (default 10); the browser test server raises it because every test pairs again. Each detached runtime records its runtime id, pid, host, port and pairing code in `/daemon.json` at mode 0600 and appends output to `/daemon.log`; reuse needs both a live pid and a healthy reply, so a stale record cannot hand you an unrelated process. Vite development access requires `OXBIT_ORIGINS=http://localhost:9279,http://127.0.0.1:9279`. Bind to loopback by default; use a TLS reverse proxy when exposing the runtime to a network. `POST /api/pair` accepts `{code}` and returns a random session token with the current workspace capabilities and trust state. It also sets an HttpOnly, SameSite=Strict cookie. WebSocket `/ws` validates the exact Origin, caps frames at 2 MiB, and requires `auth.authenticate {token}` within 10 seconds. The cookie substitutes for an omitted token. Pairing attempts are bounded per address. Persisted session records contain token hashes. @@ -39,6 +42,30 @@ UTF-8 decoder to preserve characters split across process chunks. Terminal lifecycle changes also reach the public SDK's event service. +## Identity, LAN mode and keep-alive + +Each runtime data directory holds a random runtime id in `runtime.json`, created once. `GET /api/health` returns `{ok, protocol, id, name, version, startedAt}`, where `name` is the host name and `startedAt` is when the process began listening. Successful `POST /api/pair` and `auth.authenticate` results carry the same object as `runtime`. `oxbit --status` prints the id. + +`oxbit --lan [path]` binds `0.0.0.0` on a free port (or `--port`), detached or with `--foreground`. It prints the first LAN IPv4 URL, the workspace, the pairing code and the runtime id. A runtime bound to `0.0.0.0` or `::` accepts the origin `tauri://localhost` for the iOS app, `http://
:` for each non-internal IPv4 and bracketed IPv6 address, and `http://.local:`; the list is computed per request because interfaces change. The daemon record keeps host `0.0.0.0`, and liveness probes and launch links use `127.0.0.1`. LAN mode serves plain HTTP; the pairing code and session tokens cross the network unencrypted. + +LAN mode advertises ` ()._oxbit._tcp.local` over multicast DNS from `apps/runtime/src/mdns.ts`, with no dependency. SRV points at the synthetic host `oxbit-.local` because mDNSResponder owns the machine's own `.local` name. TXT carries `id`, `name`, `version` and `port`; A and AAAA records list LAN addresses, leaving out tunnel, AWDL and link-local ones. The responder binds UDP 5353 with `reuseAddr`, announces three times one second apart, answers PTR, SRV, TXT, A, AAAA and ANY queries, and sends TTL-0 goodbyes on shutdown. A socket error prints one line and turns advertising off while the runtime keeps serving. On macOS, multicast sends fail with `EHOSTUNREACH` for a process started from an SSH (Background) session that has no Local Network access; start it from Terminal. Check an advertisement with `dns-sd -B _oxbit._tcp` and `dns-sd -L "" _oxbit._tcp`. References: [RFC 6762, Multicast DNS](https://www.rfc-editor.org/rfc/rfc6762) for the message format, cache-flush bit, announcements and goodbyes; [RFC 6763, DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763) for the PTR/SRV/TXT layout and `_services._dns-sd._udp` enumeration. + +`--keep-alive ` or `OXBIT_KEEP_ALIVE` stops a CLI runtime after no authenticated WebSocket client has been connected for the duration: `75s`, `15m`, `1h`, `8h`, plain milliseconds, or `forever`. The default is `forever`, which runs until `oxbit --stop`. The detached daemon receives the value as an argument. There is no RPC to change it; clients cannot alter a CLI runtime's keep-alive. The server pings every WebSocket client every 30 seconds and terminates one that did not answer the previous ping. Desktop remote runtimes take their lease from the launch frame; see [Remote workspaces over SSH](remote-ssh.md#installation-and-connection-lifecycle). + +The CLI exits with a message naming the found and required versions when Node is older than 24. `node-pty` loads on the first terminal request; when it cannot load, that request fails with `node-pty failed to load: ` and the rest of the runtime keeps serving. A foreground runtime that cannot bind (port in use, permission denied, address unavailable) prints one line and exits 1. + +## Runtime page + +The `oxbit.runtime` feature (`packages/features/runtime`) adds the **Runtime** tab, the `runtime.open` and `runtime.cloud` commands, a status bar item, and the `runtime.keepAlive` and `runtime.autoReconnect` settings. The page shows the connection state, host, port, version, uptime and workspace; Disconnect or Restart; workspace trust; keep-alive settings; saved runtimes; runtimes found on the network; SSH hosts with **Start Oxbit on this server**; a pairing form; and the last error, with runtime output under **Details**. Each app registers a `RuntimeConnector` (`packages/workbench/src/runtime-connector.ts`) as the `runtimeConnector` kernel service, and the page hides any control its connector does not implement. + +`runtime.cloud` backs the cloud button and `workspace.runtime`. When connected or connecting, it opens the page. With a known target (the current runtime, the most recent saved runtime, or the current desktop project), it connects and shows progress on the button, and opens the page with the reason if that fails. With no target, it opens the page. + +| App | Connector | Targets and actions | +| --- | --- | --- | +| Web | `apps/web/src/runtime-connector.ts` | Saved runtimes keyed by runtime id in browser storage; pairs by URL and code; explains failures by probing `/api/health` | +| Desktop | `packages/host-desktop/src/runtime-connector.ts` | The project's runtime; Restart restarts it; SSH recents become hosts for **Start Oxbit on this server**; `runtime.keepAlive` goes to `desktop_connection` | +| iOS | `apps/ios/src/runtime-connector.ts` | Saved and Bonjour runtimes, Keychain credentials by runtime id, SSH hosts; `runtime.keepAlive` goes to `ios_ssh_runtime_start`; settings are mirrored to app storage for the start screen | + ## Collaboration Collaboration is disabled by default on web, desktop and iOS. To enable it, open Extensions, select Collaboration and choose Enable; the choice is stored in the `extension-enabled` list and applies to later sessions. While it is off, runtime documents open with `fs.read` and save with `fs.write` against the document's saved revision, and the Participants status item, panel and remote cursors are absent. After it is enabled, newly opened documents join their rooms. Documents that are already open join at once when no text is lost: clean documents take the room text, and dirty documents join when the room has no unsaved edits at the same disk revision. Other dirty documents stay local; they join after their next save when the room has no unsaved edits, and otherwise when they are reopened. Documents restored at startup follow the same rules. Disabling it leaves every room. Open documents keep their text and save through `fs.write`, so a disk change since the last shared save is reported as a conflict. diff --git a/docs/security.md b/docs/security.md index 111ddb64..f78d5c43 100644 --- a/docs/security.md +++ b/docs/security.md @@ -13,3 +13,72 @@ Markdown disables raw HTML, sanitizes rendered output, blocks embedded images/me Runtime operation IDs retain completion/failure/interrupted status. An uncertain command or commit is inspected through operation.status; connection recovery does not start it again. Terminal replay is bounded and reports truncation. Surviving sessions reattach after socket loss; runtime process loss terminates PTYs. Shared Yjs updates persist independently of disk saves and merge on reconnect. Browser drafts remain available after failed saves and disconnected runtime services. Revoking a grant closes its connections and terminates its owned processes. Revoking workspace trust terminates workspace tools. A runtime owner remains responsible for its operating-system account and credentials. Runtime Git uses existing credential helpers and environment configuration; secrets are not sent in browser bundles. + +## Dependency audit + +Created: 2026-10-09. Last updated: 2026-10-09. + +Dependabot and secret scanning are off for the repository. Run these audits before a release: + +| Scope | Command | +| --- | --- | +| Bun workspace (`bun.lock`) | `bun audit` | +| Managed language servers | `npm audit --package-lock-only` in `apps/runtime/src/managed/npm` | +| Cargo locks | `node scripts/osv-cargo.mjs apps/desktop/src-tauri/Cargo.lock apps/ios/src-tauri/Cargo.lock apps/ios/plugins/oxbit-files/Cargo.lock` | + +`scripts/osv-cargo.mjs` sends the crates.io name and version of each locked crate to the [OSV batch API](https://google.github.io/osv.dev/post-v1-querybatch/) and prints the advisory IDs. Use `bun why ` to find the real dependency path; the chains printed by `bun audit` can name the wrong parent. + +`bun audit fix` reports in-range transitive updates as blocked, because `bun.lock` stores each dependent's resolved version. Bun applies a nested `overrides` entry to every parent of that package, so a per-parent override cannot pin different majors. Use a top-level `overrides` entry when one version fits every parent; otherwise update the lock entry's version and integrity to the patched release inside the parent's range and confirm with `bun install --frozen-lockfile`. + +### Fixed on 2026-10-09 + +| Package | Old | New | Advisories | Shipped in | +| --- | --- | --- | --- | --- | +| tar | 7.5.9 | 7.5.22 | GHSA-qffp-2rhf-9h96, GHSA-9ppj-qmqm-q256, GHSA-vmf3-w455-68vh, GHSA-w8wr-v893-vjvp, GHSA-23hp-3jrh-7fpw, GHSA-8x88-c5mf-7j5w, GHSA-gvwx-54wh-qm9j, GHSA-r292-9mhp-454m | Runtime (CLI, desktop, SSH remote) | +| markdown-it | 10.0.0 | 14.3.2 | GHSA-6v5v-wf23-fmfq, GHSA-6vfc-qv3f-vr6c, GHSA-253c-mchw-3w2r | Web, desktop, iOS | +| linkify-it | 2.2.0 | 5.0.2 | GHSA-22p9-wv53-3rq4, GHSA-v245-v573-v5vm | Web, desktop, iOS | +| sprintf-js | 1.0.3 | removed | GHSA-hp3w-g68c-fv3c | markdown-it now uses argparse 2 | +| dompurify | 3.4.15 | 3.4.16 | GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2 | Web, desktop, iOS | +| highlight.js | 9.18.5 | removed | GHSA-7wwv-vh3v-89cq | Dev (`@types/markdown-it` 14.2.0) | +| fast-uri | 3.1.7 | 3.1.8 | GHSA-hrr3-gc8f-f4qj | Dev (ajv) | +| vitest, @vitest/mocker | 3.2.7 | 4.1.11 | GHSA-82fw-gwwq-j7x9 | Dev | +| tinypool | 1.1.1 | removed | GHSA-5gmw-xhrv-c9v3, GHSA-85c8-ppgw-ccpr | Dev (vitest 4) | +| brace-expansion | 1.1.18, 2.1.4, 5.0.9 | 1.1.21, 2.1.7, 5.0.12 | GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr | Dev (eslint, typescript-eslint, mocha) | +| source-map-js | 1.2.1 | 1.2.2 | GHSA-68fv-2mgg-jv7q | Dev (postcss) | +| basic-ftp | 5.3.1 | 6.2.2 | GHSA-c475-qrg2-pj4r | Dev (wdio) | +| ip-address | 10.7.0 | 10.7.1 | GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw | Dev (wdio) | +| serialize-javascript | 6.0.2 | 7.0.5 | GHSA-5c6j-r48x-rmvq, GHSA-qj8w-gfj5-8c6v | Dev (mocha) | +| smol-toml | 1.8.0 | 1.9.0 | GHSA-r4xh-jqrq-34v2 | Dev (wdio Tauri plugin) | +| rustls | 0.23.43 | 0.23.45 | RUSTSEC-2026-0285 | Desktop | + +The managed installer rejects archive paths with `..`, hardlinks, and symlinks that resolve outside the package directory. tar reports an exception thrown from its `filter` callback as uncaught, so `apps/runtime/src/managed/install.ts` records the first rejection and throws it after extraction. + +markdown-it 14 keeps the `html: false` and `linkify: true` settings, link validation, and escaping output. Two parser changes affect rendering: a `|` inside a table code span splits the cell, as GFM specifies, and linkify keeps `*` characters inside a URL. + +Managed language servers (`apps/runtime/src/managed/npm`, installed by the runtime with lock integrity checks): + +| Package | Old | New | Advisories | +| --- | --- | --- | --- | +| bash-language-server | 5.6.0 | 5.8.1 | Moves editorconfig to 3.0.2 | +| minimatch | 10.0.1 | 10.2.6 | GHSA-3ppc-4f35-3m26, GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74 | +| brace-expansion | 2.1.4 | 5.0.12 | GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr | +| fast-uri | 3.1.7 | 3.1.8 (override) | GHSA-hrr3-gc8f-f4qj | +| source-map-js | 1.2.1 | 1.2.2 (override) | GHSA-68fv-2mgg-jv7q | +| ip-address | 10.7.0 | 10.7.3 (override) | GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw | +| protobufjs | 8.3.0 | 8.8.0 (override) | GHSA-94rc-8x27-4472, GHSA-wcpc-wj8m-hjx6, GHSA-f38q-mgvj-vph7, GHSA-jfj6-75fj-8934, GHSA-j3f2-48v5-ccww | +| basic-ftp | 5.3.1 | 6.2.2 (override) | GHSA-c475-qrg2-pj4r | +| @opentelemetry/core, resources, sdk-trace-base | 1.30.1 | 2.12.0 (override) | GHSA-8988-4f7v-96qf | + +intelephense stays at 1.18.5, the latest release; `npm audit` suggests 1.18.2, a downgrade. `lib/intelephense.js` bundles applicationinsights, proxy-agent, and micromatch, and requires only Node built-ins, so the overridden copies in `node_modules` are downloaded and not loaded. Telemetry is off in `apps/runtime/src/managed/catalog.ts`. + +### Accepted advisories + +| Package | Advisory | Path | Reason | +| --- | --- | --- | --- | +| braces 3.0.3 | GHSA-vfj7-8cjw-p6xm | mocha 10 > chokidar 3.6.0 (wdio, dev) | No patched release. Mocha watch mode only; desktop WebDriver tests do not use it. | +| braces 3.0.3 | GHSA-vfj7-8cjw-p6xm | Managed lock: micromatch and fast-glob in bash-language-server and intelephense | No patched release. bash-language-server expands its `globPattern` setting (default `**/*@(.sh\|.inc\|.bash\|.command)`) with fast-glob; a nested-brace pattern needs control of the server settings. intelephense runs its bundled copy. | +| extract-zip 2.0.1 | GHSA-jmr9-qjv8-65gv, GHSA-7pqw-9j4j-h8q3 | wdio > @puppeteer/browsers 2.x (dev) | No patched release. Extracts browser driver archives during desktop WebDriver tests. @puppeteer/browsers 3 drops it and needs WebdriverIO 10. | +| rsa 0.9.10, 0.10.0-rc.18 | RUSTSEC-2023-0071 | iOS: russh 0.64.1 and ssh-key with the `rsa` feature; pgp 0.21.0 in oxbit-files | No upstream fix. New SSH and commit-signing keys are Ed25519 (`apps/ios/src-tauri/src/ssh/keys.rs`, `apps/ios/plugins/oxbit-files/src/commit_signing/keys.rs`). An imported RSA key signs SSH authentication and commits. The Marvin attack targets RSA decryption, which Oxbit does not call; russh has no RSA key exchange. | +| glib 0.18.5 | RUSTSEC-2024-0429 | gtk, wry, webkit2gtk on Linux | Linux desktop packages (AppImage, .deb) only. No crate in the tree calls `VariantStrIter`. | +| proc-macro-error 1.0.4 | RUSTSEC-2024-0370 (unmaintained) | glib-macros, gtk3-macros | Compile-time only, Linux only. | +| unic-* 0.9.0 | RUSTSEC-2025-0075, -0080, -0081, -0098, -0100 (unmaintained) | tauri-utils > urlpattern | Used to match remote URL capabilities; the desktop and iOS capability files define no remote URLs. | diff --git a/docs/settings.md b/docs/settings.md index 9e24c09e..755a5ac3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,5 +1,7 @@ # JSON settings +Created: 2026-09-08. Last updated: 2026-10-09. + Oxbit merges settings in this order, from lowest to highest priority: 1. Built-in defaults. @@ -84,6 +86,15 @@ The header has three rows: the title with a Keyboard Shortcuts button, the searc The All Settings and Source Control categories show a Git Accounts and Commit Author… button when the `git.account` command is registered and enabled. iOS registers it; desktop and web hide the button. +## Runtime settings + +| Setting | Values | Effect | +| --- | --- | --- | +| `runtime.keepAlive` | `75s` (default), `15m`, `1h`, `8h`, `untilStopped` | How long a runtime that Oxbit starts over SSH, from the desktop or iOS app, keeps running after its last client disconnects. CLI runtimes use `oxbit --keep-alive` instead. | +| `runtime.autoReconnect` | `true` (default), `false` | Reconnect a URL runtime after its connection drops, and on iOS when the app returns to the foreground. | + +Both appear in the **Runtime** category and on the Runtime page. See [Runtime page](runtime.md#runtime-page). + ## Verification `tests/browser/settings-header.spec.ts` checks header control heights at 320px, 393px and 1280px, the matching row above a 400px keyboard viewport on `webkit-phone`, and the Git Accounts visibility rule. diff --git a/docs/testflight/0.5.0.md b/docs/testflight/0.5.0.md new file mode 100644 index 00000000..a829202e --- /dev/null +++ b/docs/testflight/0.5.0.md @@ -0,0 +1,19 @@ +Runtime page, one-tap connect, and keep-alive + +1. Open a device folder and tap the cloud button in the header. With no runtime saved yet, check that the Runtime page opens. It lists saved runtimes, runtimes on this network, SSH servers, keep-alive settings, and a pairing form. + +2. On a Mac or Linux computer on the same Wi-Fi, install the CLI (npm install -g @oxbit/cli) and run oxbit --lan in a project folder. Allow Local Network access when iOS asks. Check that the runtime appears under "On this network" within a few seconds. Tap it, enter the pairing code the computer printed, and check that the workspace opens. + +3. Close the workspace. On the start screen, tap Connect Runtime… and check that it connects to the same runtime without asking for the pairing code. In a device folder, tap the cloud button and check that it connects the same way and shows progress on the button. + +4. Stop the runtime on the computer (oxbit --stop). Tap the cloud button and check that the Runtime page opens with the reason the connection failed. + +5. On the Runtime page, set Keep Runtime Alive to 15 minutes. Start Oxbit on an SSH server, switch to another app for 5 minutes, and come back. Check that the workspace reconnects without starting the runtime again. Repeat with 75 seconds and a 3-minute wait, and check that the runtime was started again. + +6. Turn off Reconnect Automatically, turn on Airplane Mode briefly, turn it off, and check that the workspace offers Reconnect instead of reconnecting by itself. + +7. If you have a Linux arm64 server (for example a Raspberry Pi 5 or an AWS Graviton host), start Oxbit on it over SSH and check that the folder opens. + +8. Open a Markdown file with links, a table, and inline code, and check that the preview renders them. + +Report crashes, runtimes that do not appear on the network, pairing or reconnect failures, or a runtime that stops before its keep-alive time, with the computer or server OS, reproduction steps, device model, iOS version, and screenshots. diff --git a/docs/theme-coverage.md b/docs/theme-coverage.md index 80e98f8e..eeeef276 100644 --- a/docs/theme-coverage.md +++ b/docs/theme-coverage.md @@ -4,13 +4,14 @@ Generated by `bun run themes:coverage`. Every shared palette variable maps to a | Surface source | Catalog roles consumed | | --- | --- | -| `apps/desktop/src/desktop.css` | `colors.accent.primary`, `colors.border.default`, `colors.control.hover.background`, `colors.diagnostic.warning`, `colors.list.selection.background`, `colors.surface.background`, `colors.text.muted`, `colors.text.primary`, `colors.workbench.background`, `typography.body`, `typography.small` | +| `apps/desktop/src/desktop.css` | `colors.accent.primary`, `colors.border.default`, `colors.control.hover.background`, `colors.diagnostic.warning`, `colors.list.selection.background`, `colors.surface.background`, `colors.text.muted`, `colors.text.primary`, `colors.workbench.background`, `typography.body`, `typography.mono`, `typography.small` | | `apps/web/src/main.tsx` | `colors.text.primary`, `typography.body` | | `packages/features/editor/src/index.tsx` | `colors.border.default`, `colors.diagnostic.error`, `colors.diagnostic.info`, `colors.diagnostic.warning`, `colors.editor.activeLine.background`, `colors.editor.background`, `colors.editor.cursor`, `colors.editor.guide`, `colors.editor.gutter.background`, `colors.editor.gutter.foreground`, `colors.editor.occurrence.background`, `colors.editor.search.activeBackground`, `colors.editor.search.background`, `colors.editor.selection.background`, `colors.editor.selection.foreground`, `colors.editor.selection.inactiveBackground`, `colors.input.background`, `colors.list.selection.background`, `colors.popover.background`, `colors.surface.background`, `colors.text.primary`, `effects.shadow`, `syntax.attr`, `syntax.comment`, `syntax.function`, `syntax.heading`, `syntax.ident`, `syntax.keyword`, `syntax.link`, `syntax.number`, `syntax.operator`, `syntax.property`, `syntax.punct`, `syntax.string`, `syntax.tag`, `syntax.text`, `syntax.type`, `syntax.variable` | | `packages/features/git/src/index.ts` | `colors.diff.added.background`, `colors.diff.deleted.background`, `colors.editor.background`, `typography.mono` | | `packages/features/language/src/log-links.ts` | `syntax.link` | | `packages/features/language/src/navigation.tsx` | `colors.editor.background`, `colors.editor.gutter.background`, `colors.editor.gutter.foreground`, `syntax.text`, `typography.editor` | | `packages/features/language/src/overlays.ts` | `colors.editor.occurrence.background`, `colors.popover.background`, `colors.text.muted`, `syntax.link` | +| `packages/features/runtime/src/runtime.css` | `colors.accent.background`, `colors.accent.primary`, `colors.border.default`, `colors.border.strong`, `colors.diagnostic.error`, `colors.diagnostic.success`, `colors.diagnostic.warning`, `colors.editor.background`, `colors.surface.background`, `colors.text.muted`, `colors.text.primary`, `colors.text.secondary`, `typography.heading`, `typography.label`, `typography.mono`, `typography.small` | | `packages/features/terminal/src/index.ts` | `typography.small` | | `packages/ui/src/classicos98.css` | `colors.accent.foreground`, `colors.accent.primary`, `colors.editor.background`, `colors.input.background`, `colors.list.selection.background`, `colors.selection.foreground`, `colors.text.primary`, `colors.workbench.background`, `effects.shadow` | | `packages/ui/src/tokens.css` | `colors.accent.background`, `colors.accent.primary`, `colors.border.default`, `colors.border.strong`, `colors.focus.border`, `colors.link.foreground`, `colors.link.hoverForeground`, `colors.popover.background`, `colors.popover.foreground`, `colors.selection.background`, `colors.selection.foreground`, `colors.text.primary`, `colors.workbench.background`, `typography.body` | diff --git a/package.json b/package.json index 04f09d65..4d255cba 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ }, "devDependencies": { "@playwright/test": "1.62.1", - "@types/markdown-it": "^10.0.3", + "@types/markdown-it": "^14.2.0", "@types/node": "^24.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -94,10 +94,16 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.42.0", "vite": "^7.1.5", - "vitest": "^3.2.4" + "vitest": "^4.1.11" }, "overrides": { - "@xterm/addon-ligatures": "0.11.0-beta.213" + "@xterm/addon-ligatures": "0.11.0-beta.213", + "basic-ftp": "6.2.2", + "fast-uri": "3.1.8", + "ip-address": "10.7.1", + "serialize-javascript": "7.0.5", + "smol-toml": "1.9.0", + "source-map-js": "1.2.2" }, "trustedDependencies": [ "esbuild", diff --git a/packages/app-workbench/package.json b/packages/app-workbench/package.json index 77f9c965..860ec489 100644 --- a/packages/app-workbench/package.json +++ b/packages/app-workbench/package.json @@ -19,6 +19,7 @@ "@oxbit/feature-editor": "workspace:*", "@oxbit/feature-explorer": "workspace:*", "@oxbit/feature-settings": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-extensions": "workspace:*", "@oxbit/feature-themes": "workspace:*", "@oxbit/feature-keymaps": "workspace:*", diff --git a/packages/app-workbench/src/index.ts b/packages/app-workbench/src/index.ts index cbdcf203..53a6a1b1 100644 --- a/packages/app-workbench/src/index.ts +++ b/packages/app-workbench/src/index.ts @@ -3,7 +3,7 @@ import { translate as tr } from "@oxbit/ui"; import { createKernel } from "@oxbit/core"; import { DocumentService } from "@oxbit/documents"; import { RuntimeClient } from "@oxbit/host-runtime"; -import { WorkbenchController, createWorkbenchFeature } from "@oxbit/workbench"; +import { WorkbenchController, createWorkbenchFeature, RUNTIME_CONNECTOR_SERVICE, type RuntimeConnector } from "@oxbit/workbench"; import { languageForKernel, type FileSystem, @@ -14,6 +14,7 @@ import { import { createFeature as editorFeature } from "@oxbit/feature-editor"; import { createFeature as explorerFeature } from "@oxbit/feature-explorer"; import { createFeature as settingsFeature } from "@oxbit/feature-settings"; +import { createFeature as runtimeFeature } from "@oxbit/feature-runtime"; import { createFeature as extensionsFeature } from "@oxbit/feature-extensions"; import { createFeature as themesFeature, @@ -86,6 +87,7 @@ export async function createWorkbenchSession({ preserveFilesystem = false, additionalExtensionOrigins, iconPackStore, + runtimeConnector, }: { filesystem: FileSystem; runtime?: RuntimeClient; @@ -96,6 +98,8 @@ export async function createWorkbenchSession({ preserveFilesystem?: boolean; additionalExtensionOrigins?: readonly string[]; iconPackStore?: PackStore; + /** Backs the Runtime page and the one-tap cloud button. */ + runtimeConnector?: RuntimeConnector; }): Promise { const configurationPersistence = new ScopedConfigurationPersistence( persistence, @@ -153,6 +157,7 @@ export async function createWorkbenchSession({ kernel.services.register("filesystem", filesystem); kernel.services.register("persistence", persistence); if (runtime) kernel.services.register("runtime", runtime); + if (runtimeConnector) kernel.services.register(RUNTIME_CONNECTOR_SERVICE, runtimeConnector); kernel.context.set("workspace", true); kernel.context.set("connected", !!runtime?.connected); kernel.context.set("trusted", !!runtime?.session?.trusted); @@ -179,6 +184,7 @@ export async function createWorkbenchSession({ }); const features = [ settingsFeature(options), + runtimeFeature(options), themesFeature(options), createVSCodeFeature(options), createVSCodeHighContrastFeature(options), diff --git a/packages/app-workbench/tsconfig.json b/packages/app-workbench/tsconfig.json index 5c1c6657..24b4d484 100644 --- a/packages/app-workbench/tsconfig.json +++ b/packages/app-workbench/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../features/settings" }, + { + "path": "../features/runtime" + }, { "path": "../features/extensions" }, diff --git a/packages/features/agent-acp/package.json b/packages/features/agent-acp/package.json index cedf5328..1aff5175 100644 --- a/packages/features/agent-acp/package.json +++ b/packages/features/agent-acp/package.json @@ -11,8 +11,8 @@ "@oxbit/ui": "workspace:*", "@oxbit/workbench": "workspace:*", "diff": "8.0.4", - "dompurify": "^3.2.7", - "markdown-it": "^10.0.0", + "dompurify": "^3.4.16", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/packages/features/language/package.json b/packages/features/language/package.json index 0b96fbec..1ae169ea 100644 --- a/packages/features/language/package.json +++ b/packages/features/language/package.json @@ -18,7 +18,7 @@ "@oxbit/documents": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/ui": "workspace:*", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", "vscode-json-languageservice": "5.6.1", diff --git a/packages/features/language/src/index.ts b/packages/features/language/src/index.ts index 2ee051b7..a005d7a0 100644 --- a/packages/features/language/src/index.ts +++ b/packages/features/language/src/index.ts @@ -1118,7 +1118,7 @@ export class LanguageService { return; this.diagnosticVersions.set(path, params.version); } - if (this.disposed) return; + if (this.disposed || !this.fileEnabled(path)) return; this.lspDiagnostics.set(path, params.diagnostics ?? []); this.refreshDiagnostics(path); if (this.providerContext) this.providerContext.owner.providersChanged(); diff --git a/packages/features/previews/package.json b/packages/features/previews/package.json index db8d8db8..83ab376b 100644 --- a/packages/features/previews/package.json +++ b/packages/features/previews/package.json @@ -11,9 +11,9 @@ "react-dom": "19.1.0", "@oxbit/documents": "workspace:*", "@oxbit/ui": "workspace:*", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "htmlparser2": "^12.0.0", - "dompurify": "^3.2.7" + "dompurify": "^3.4.16" }, "license": "MIT", "author": { diff --git a/packages/features/runtime/package.json b/packages/features/runtime/package.json new file mode 100644 index 00000000..879248d9 --- /dev/null +++ b/packages/features/runtime/package.json @@ -0,0 +1,22 @@ +{ + "name": "@oxbit/feature-runtime", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": "./src/index.tsx", + "scripts": {}, + "dependencies": { + "@oxbit/sdk": "workspace:*", + "@oxbit/protocol": "workspace:*", + "react": "19.1.0", + "react-dom": "19.1.0", + "@oxbit/ui": "workspace:*", + "@oxbit/workbench": "workspace:*" + }, + "license": "MIT", + "author": { + "name": "Ryan Yannelli", + "email": "ryanyannelli@gmail.com", + "url": "https://github.com/yannelli" + } +} diff --git a/packages/features/runtime/src/configuration.ts b/packages/features/runtime/src/configuration.ts new file mode 100644 index 00000000..9935d37d --- /dev/null +++ b/packages/features/runtime/src/configuration.ts @@ -0,0 +1,41 @@ +import type { Setting } from "@oxbit/sdk"; + +export const KEEP_ALIVE_SETTING = "runtime.keepAlive"; +export const AUTO_RECONNECT_SETTING = "runtime.autoReconnect"; + +const keepAliveDurations = { + "75s": 75_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "8h": 8 * 60 * 60_000, + untilStopped: 0, +} as const; +export type KeepAlive = keyof typeof keepAliveDurations; +export const keepAliveOptions = Object.keys(keepAliveDurations) as KeepAlive[]; + +/** Milliseconds for the launch frame; 0 runs until stopped. Unknown values use the default. */ +export function keepAliveMs(value: unknown): number { + return keepAliveDurations[(value as KeepAlive) in keepAliveDurations ? (value as KeepAlive) : "75s"]; +} + +export const runtimeConfiguration: Setting[] = [ + { + id: KEEP_ALIVE_SETTING, + title: "Keep Runtime Alive", + description: + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + type: "string", + category: "Runtime", + default: "75s", + enum: keepAliveOptions, + }, + { + id: AUTO_RECONNECT_SETTING, + title: "Reconnect Automatically", + description: + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.", + type: "boolean", + category: "Runtime", + default: true, + }, +]; diff --git a/packages/features/runtime/src/index.tsx b/packages/features/runtime/src/index.tsx new file mode 100644 index 00000000..566bbdf6 --- /dev/null +++ b/packages/features/runtime/src/index.tsx @@ -0,0 +1,77 @@ +import { useSyncExternalStore } from "react"; +import { Icon, translate as tr } from "@oxbit/ui"; +import type { Extension, Kernel } from "@oxbit/sdk"; +import { RUNTIME_CONNECTOR_SERVICE, type RuntimeConnector, type RuntimeStatus, type WorkbenchController } from "@oxbit/workbench"; +import { runtimeConfiguration } from "./configuration.js"; +import { RuntimePage } from "./page.js"; +import type { RuntimeSettingsAccess } from "./sections.js"; +import { oneTap } from "./store.js"; +import "./runtime.css"; + +export { RuntimePage } from "./page.js"; +export type { RuntimeSettingsAccess } from "./sections.js"; +export { RuntimeStatusStore, clientStatus, oneTap, formatUptime, type RuntimeClientLike } from "./store.js"; +export { runtimeConfiguration, keepAliveMs, keepAliveOptions, KEEP_ALIVE_SETTING, AUTO_RECONNECT_SETTING, type KeepAlive } from "./configuration.js"; + +const connectorOf = (kernel: Kernel) => kernel.services.optional(RUNTIME_CONNECTOR_SERVICE); +const idle: RuntimeStatus = { state: "disconnected" }; +const noSubscription = () => () => {}; + +export function kernelSettings(kernel: Kernel): RuntimeSettingsAccess { + return { + get: id => kernel.configuration.get(id), + set: (id, value) => kernel.configuration.set(id, value as never), + }; +} + +function RuntimeTab({ kernel }: { kernel: Kernel; workbench: WorkbenchController }) { + const connector = connectorOf(kernel); + if (!connector) + return

{tr("This window has no runtime connection.")}

; + return ; +} + +function RuntimeStatusItem({ kernel, workbench }: { kernel: Kernel; workbench: WorkbenchController }) { + const connector = connectorOf(kernel); + const status = useSyncExternalStore(connector?.subscribe ?? noSubscription, connector?.status ?? (() => idle)); + const busy = status.state === "connecting" || status.state === "reconnecting"; + const label = status.state === "connected" ? status.name ?? tr("Connected") + : busy ? tr("Connecting…") : status.state === "failed" ? tr("Connection failed") : tr("Local"); + return ( + + ); +} + +export function createFeature({ kernel, workbench }: { kernel: Kernel; workbench: WorkbenchController }): Extension { + const open = () => void workbench.openView("runtime", "Runtime", RuntimeTab, {}, { contributionId: "runtime" }); + return { + manifest: { + manifestVersion: 1, + id: "oxbit.runtime", + name: "Runtime", + version: "1.0.0", + sdk: "^1.0.0", + description: "Connect, pair, and keep runtimes alive from one page.", + environments: ["browser", "embedded"], + activation: ["*"], + capabilities: [], + configuration: runtimeConfiguration.map(setting => ({ ...setting, enum: setting.enum?.slice() })), + }, + activate(ctx) { + ctx.own(ctx.contributions.register({ id: "runtime", kind: "tab", title: "Runtime", component: RuntimeTab })); + ctx.own(ctx.contributions.register({ + id: "runtime.status", kind: "statusItem", title: "Runtime connection", + component: () => , + })); + ctx.own(ctx.commands.register({ id: "runtime.open", title: "Open Runtime", category: "Runtime", run: open })); + ctx.own(ctx.commands.register({ + id: "runtime.cloud", title: "Connect Runtime", category: "Runtime", + run: () => oneTap(connectorOf(kernel), open), + })); + }, + }; +} diff --git a/packages/features/runtime/src/page.tsx b/packages/features/runtime/src/page.tsx new file mode 100644 index 00000000..27f5ca68 --- /dev/null +++ b/packages/features/runtime/src/page.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { Icon, translate as tr } from "@oxbit/ui"; +import type { RuntimeConnector, RuntimeStatus } from "@oxbit/workbench"; +import { Card, DiscoveredRuntimes, KeepAliveSettings, SavedRuntimes, SshServers, type RuntimeSettingsAccess } from "./sections.js"; +import { formatUptime } from "./store.js"; + +const stateLabels: Record = { + connected: "Connected", + connecting: "Connecting…", + reconnecting: "Reconnecting…", + disconnected: "Not connected", + failed: "Connection failed", +}; +const stateIcons: Record = { + connected: "cloudCheck", + connecting: "sync", + reconnecting: "sync", + disconnected: "cloud", + failed: "cloudOff", +}; + +export function RuntimePage({ connector, settings, onClose }: { + connector: RuntimeConnector; + settings?: RuntimeSettingsAccess; + onClose?: () => void; +}) { + const status = useSyncExternalStore(connector.subscribe, connector.status); + const [busy, setBusy] = useState(); + const [actionError, setActionError] = useState(); + const [, tick] = useState(0); + const pairCode = useRef(null); + const [pairUrl, setPairUrl] = useState(""); + useEffect(() => { + const timer = setInterval(() => tick(value => value + 1), 30_000); + return () => clearInterval(timer); + }, []); + useEffect(() => { + if (!pairUrl && status.url && status.kind !== "local") setPairUrl(status.url); + }, [status.url, status.kind, pairUrl]); + const perform = useCallback((key: string, action: () => Promise) => { + if (busy) return; + setBusy(key); + setActionError(undefined); + void action().catch(error => setActionError(error instanceof Error ? error.message : String(error))).finally(() => setBusy(undefined)); + }, [busy]); + const connected = status.state === "connected"; + const quick = connected ? undefined : connector.quickTarget(); + const uptime = connected ? formatUptime(status.startedAt) : undefined; + const pairWith = (url: string) => { + setPairUrl(url); + pairCode.current?.scrollIntoView({ block: "center", behavior: "smooth" }); + pairCode.current?.focus(); + }; + const facts: [string, string | undefined][] = [ + [tr("Host"), status.host], + [tr("Port"), status.port ? String(status.port) : undefined], + [tr("Version"), status.version], + [tr("Uptime"), uptime], + [tr("Workspace"), status.workspace], + ]; + return ( +
+
+
+
+
+

{tr("Runtime")}

+ {onClose && } +
+

+ {status.name ?? quick?.name ?? tr("No runtime connected")} + {tr(stateLabels[status.state])} +

+ {status.progress && status.state !== "connected" &&

} + {facts.some(([, value]) => value) && ( +
+ {facts.filter(([, value]) => value).map(([label, value]) =>
{label}
{value}
)} +
+ )} +
+ {connected ? <> + {connector.disconnect && } + {connector.restart && } + : quick ? ( + + ) : connector.pair && ( + + )} + {!connected && connector.restart && status.kind && } +
+
+
+ {(status.error && status.state !== "connected" || actionError) && ( +
+ +
+ {actionError ?? status.error!.message} + {!actionError && status.error?.detail && ( +
{tr("Details")}
{status.error.detail}
+ )} +
+
+ )} +
+ {connected && connector.trust && connector.trust!(value)} />} + {settings && } + + {connector.discover && } + {connector.sshHosts && connector.startSsh && } + {connector.pair && } +
+
+ ); +} + +function TrustCard({ status, busy, perform, trust }: { + status: RuntimeStatus; busy?: string; perform: (key: string, action: () => Promise) => void; trust: (value: boolean) => Promise; +}) { + return ( + +

{tr("Trust allows terminals, tasks, Git hooks and language servers to execute workspace code.")}

+ + {status.owner === false &&

{tr("Only the workspace owner can change trust.")}

} +
+ ); +} + +function PairCard({ pair, startCommand, url, setUrl, codeRef, busy, perform }: { + pair: (url: string, code: string) => Promise; startCommand?: string; url: string; setUrl: (value: string) => void; codeRef: React.RefObject; + busy?: string; perform: (key: string, action: () => Promise) => void; +}) { + const [code, setCode] = useState(""); + return ( + +
{ event.preventDefault(); perform("pair", async () => { await pair(url.trim(), code.trim()); setCode(""); }); }}> + {startCommand && <> +

{tr("Start a runtime on your computer, then enter its address and owner pairing code.")}

+ {startCommand} + } + + +
+ +
+
+
+ ); +} diff --git a/packages/features/runtime/src/runtime.css b/packages/features/runtime/src/runtime.css new file mode 100644 index 00000000..02c36c07 --- /dev/null +++ b/packages/features/runtime/src/runtime.css @@ -0,0 +1,107 @@ +.runtime-page { + height: 100%; + overflow: auto; + box-sizing: border-box; + padding: 24px 28px 48px; +} +.runtime-page > * { max-width: 1080px; } +.runtime-hero { display: flex; gap: 20px; margin-bottom: 20px; } +.runtime-hero-icon { + width: 80px; height: 80px; flex: none; display: grid; place-items: center; + border-radius: 10px; background: var(--accent-soft); color: var(--accent); +} +.runtime-hero[data-state=connected] .runtime-hero-icon { color: var(--ok); } +.runtime-hero[data-state=failed] .runtime-hero-icon { color: var(--err); } +.runtime-hero:is([data-state=connecting],[data-state=reconnecting]) .runtime-hero-icon svg { animation: runtime-spin 1.2s linear infinite; } +.runtime-hero-body { flex: 1; min-width: 0; } +.runtime-title-row { display: flex; align-items: center; gap: 8px; } +.runtime-title-row > h1 { flex: 1; margin: 0; } +.runtime-name { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 10px; color: var(--fg-2); overflow-wrap: anywhere; } +.runtime-badge { + display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px; border-radius: 999px; + border: 1px solid var(--bd-strong); color: var(--fg-2); font-size: var(--font-small-font-size); white-space: nowrap; +} +.runtime-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--fg-3); } +.runtime-badge[data-state=connected]::before { background: var(--ok); } +.runtime-badge:is([data-state=connecting],[data-state=reconnecting])::before { background: var(--warn); } +.runtime-badge[data-state=failed]::before { background: var(--err); } +.runtime-facts { display: flex; flex-wrap: wrap; gap: 6px 22px; margin: 0 0 14px; } +.runtime-facts > div { min-width: 0; } +.runtime-facts dt { color: var(--fg-3); font-size: var(--font-small-font-size); } +.runtime-facts dd { margin: 2px 0 0; font-family: var(--font-mono); font-size: var(--font-mono-font-size); overflow-wrap: anywhere; } +.runtime-progress, .runtime-searching { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; color: var(--fg-2); } +.runtime-spinner { + width: 12px; height: 12px; flex: none; border-radius: 50%; + border: 2px solid var(--bd-strong); border-top-color: var(--accent); animation: runtime-spin 0.9s linear infinite; +} +@keyframes runtime-spin { to { transform: rotate(360deg); } } +.runtime-error { + display: flex; gap: 12px; align-items: flex-start; margin-bottom: 20px; padding: 12px 14px; + border: 1px solid var(--err); border-radius: 8px; background: color-mix(in srgb, var(--err) 10%, transparent); +} +.runtime-error > svg { color: var(--err); flex: none; margin-top: 2px; } +.runtime-error > div { min-width: 0; flex: 1; } +.runtime-error summary { margin-top: 6px; cursor: pointer; color: var(--fg-2); } +.runtime-error pre { + margin: 8px 0 0; max-height: 240px; overflow: auto; padding: 8px 10px; border-radius: 6px; + background: var(--bg-editor); font-family: var(--font-mono); font-size: var(--font-mono-font-size); white-space: pre-wrap; overflow-wrap: anywhere; +} +.runtime-sections { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; align-items: start; } +.runtime-card { + display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 14px 16px; + border: 1px solid var(--bd); border-radius: 8px; background: var(--bg-surface); +} +.runtime-card.wide { grid-column: 1 / -1; } +.runtime-card > h2 { + display: flex; align-items: center; gap: 8px; margin: 0; color: var(--fg-2); + font-size: var(--font-label-font-size); font-weight: var(--font-label-font-weight); text-transform: uppercase; letter-spacing: 0.04em; +} +.runtime-card .custom-select { width: 100%; max-width: 320px; } +.runtime-hint { margin: 0; color: var(--fg-3); font-size: var(--font-small-font-size); } +.runtime-switch { display: flex; align-items: center; gap: 10px; min-height: 28px; cursor: pointer; } +.runtime-switch > input[type=checkbox] { + appearance: none; -webkit-appearance: none; display: block; flex: none; position: relative; margin: 0; padding: 0; + width: 34px; height: 20px; min-width: 34px; min-height: 20px; border: 0; border-radius: 999px; + background: var(--bd-strong); transition: background 0.15s; cursor: inherit; +} +.runtime-switch > input[type=checkbox]::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border: 0; border-radius: 50%; + background: var(--bg-editor); transform: none; transition: transform 0.15s; +} +.runtime-switch > input[type=checkbox]:checked { background: var(--accent); } +.runtime-switch > input[type=checkbox]:checked::after { + width: 16px; height: 16px; border: 0; background: var(--bg-editor); transform: translateX(14px); +} +.runtime-switch > input[type=checkbox]:disabled { opacity: 0.5; } +.runtime-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.runtime-list > li { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--bd); } +.runtime-list > li:first-child { border-top: 0; } +.runtime-list > li > svg { color: var(--fg-3); flex: none; } +.runtime-list > li[aria-current=true] > svg { color: var(--ok); } +.runtime-list-text { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-wrap: anywhere; } +.runtime-list-text > small { color: var(--fg-3); font-size: var(--font-small-font-size); } +.runtime-ssh { flex-wrap: wrap; } +.runtime-ssh > input { flex: 1 1 140px; min-width: 0; height: 28px; font-family: var(--font-mono); } +.runtime-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px 16px; align-items: end; } +.runtime-pair > :is(p, .runtime-command, .toolbar) { grid-column: 1 / -1; } +.runtime-pair label { display: flex; flex-direction: column; gap: 4px; color: var(--fg-2); } +.runtime-pair input { height: 30px; } +.runtime-command { + display: block; padding: 8px 10px; border-radius: 6px; background: var(--bg-editor); + font-family: var(--font-mono); font-size: var(--font-mono-font-size); user-select: text; -webkit-user-select: text; overflow-wrap: anywhere; +} +.runtime-status-item[data-state=connected] { color: var(--fg); } +.runtime-status-item[data-state=failed] > svg { color: var(--err); } +.runtime-status-item[aria-busy=true] > svg, .connection-button[aria-busy=true] > svg { animation: runtime-spin 1.2s linear infinite; } + +@media (max-width: 599px) { + .runtime-page { padding: 16px 12px calc(32px + env(safe-area-inset-bottom)); } + .runtime-hero { gap: 12px; } + .runtime-hero-icon { width: 48px; height: 48px; border-radius: 8px; } + .runtime-hero-icon svg { width: 28px; height: 28px; } + .runtime-title-row > h1 { font-size: calc(var(--font-heading-font-size) * 0.85); } + .runtime-sections { grid-template-columns: 1fr; gap: 12px; } + .runtime-hero .button, .runtime-card .button { min-height: 40px; } + .runtime-list > li { padding: 10px 0; } + .runtime-pair input, .runtime-ssh > input { height: 40px; } +} diff --git a/packages/features/runtime/src/runtime.test.ts b/packages/features/runtime/src/runtime.test.ts new file mode 100644 index 00000000..d860f188 --- /dev/null +++ b/packages/features/runtime/src/runtime.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RuntimeConnector, RuntimeStatus, RuntimeTarget } from "@oxbit/workbench"; +import { keepAliveMs, keepAliveOptions } from "./configuration.js"; +import { RuntimeStatusStore, clientStatus, formatUptime, oneTap } from "./store.js"; + +const target: RuntimeTarget = { key: "url:abc12345", kind: "url", name: "studio", url: "http://192.168.1.5:50123", runtimeId: "abc12345" }; + +function connector(state: RuntimeStatus["state"], quick?: RuntimeTarget, connect = vi.fn(async () => {})): RuntimeConnector { + return { + status: () => ({ state }), + subscribe: () => () => {}, + quickTarget: () => quick, + connect, + pair: vi.fn(), + recents: async () => [], + }; +} + +describe("keep-alive setting", () => { + it("maps every option to milliseconds and falls back to the default", () => { + expect(keepAliveOptions).toEqual(["75s", "15m", "1h", "8h", "untilStopped"]); + expect(keepAliveMs("15m")).toBe(900_000); + expect(keepAliveMs("untilStopped")).toBe(0); + expect(keepAliveMs("bogus")).toBe(75_000); + expect(keepAliveMs(undefined)).toBe(75_000); + }); +}); + +describe("one-tap cloud", () => { + it("opens the page when connected", async () => { + const open = vi.fn(); + const runtime = connector("connected", target); + await oneTap(runtime, open); + expect(open).toHaveBeenCalledOnce(); + expect(runtime.connect).not.toHaveBeenCalled(); + }); + it("connects to the known target without opening the page", async () => { + const open = vi.fn(); + const runtime = connector("disconnected", target); + await oneTap(runtime, open); + expect(runtime.connect).toHaveBeenCalledWith(target); + expect(open).not.toHaveBeenCalled(); + }); + it("opens the page when the connection fails", async () => { + const open = vi.fn(); + await oneTap(connector("failed", target, vi.fn(async () => { throw new Error("refused"); })), open); + expect(open).toHaveBeenCalledOnce(); + }); + it("opens the page when nothing is known or there is no connector", async () => { + const open = vi.fn(); + await oneTap(connector("disconnected"), open); + await oneTap(undefined, open); + expect(open).toHaveBeenCalledTimes(2); + }); +}); + +describe("status store", () => { + it("splits runtime output from a failure and notifies listeners", () => { + const store = new RuntimeStatusStore(); + const listener = vi.fn(); + store.subscribe(listener); + store.fail(new Error("Runtime startup failed\n--- runtime output ---\nError: Cannot find module 'node-pty'")); + expect(store.get()).toMatchObject({ state: "failed", error: { message: "Runtime startup failed", detail: "Error: Cannot find module 'node-pty'" } }); + expect(listener).toHaveBeenCalledOnce(); + }); + it("describes a client from its URL and identity", () => { + expect(clientStatus({ + url: "http://10.0.0.4:51234", connected: true, + identity: { id: "abc12345", name: "studio", version: "0.4.2", startedAt: 1 }, + session: { trusted: true, owner: true, workspaceName: "oxbit" }, + }, "url")).toMatchObject({ host: "10.0.0.4", port: 51234, name: "studio", version: "0.4.2", runtimeId: "abc12345", workspace: "oxbit", trusted: true }); + }); + it("formats uptime", () => { + expect(formatUptime(undefined)).toBeUndefined(); + expect(formatUptime(0, 30_000)).toBeUndefined(); + expect(formatUptime(1, 30_000)).toBe("<1m"); + expect(formatUptime(1, 1 + 125 * 60_000)).toBe("2h 5m"); + expect(formatUptime(1, 1 + 26 * 3_600_000)).toBe("1d 2h"); + }); +}); diff --git a/packages/features/runtime/src/sections.tsx b/packages/features/runtime/src/sections.tsx new file mode 100644 index 00000000..776a06ec --- /dev/null +++ b/packages/features/runtime/src/sections.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Icon, Select, translate as tr } from "@oxbit/ui"; +import type { DiscoveredRuntime, RuntimeConnector, RuntimeTarget, SshHostSummary } from "@oxbit/workbench"; +import { AUTO_RECONNECT_SETTING, KEEP_ALIVE_SETTING, keepAliveOptions } from "./configuration.js"; + +/** Reads and writes runtime settings with or without a workbench kernel. */ +export interface RuntimeSettingsAccess { + get(id: string): T | undefined; + set(id: string, value: unknown): void | Promise; +} + +export type Perform = (key: string, action: () => Promise) => void; + +export function Card({ title, icon, children, wide = false }: { title: string; icon: string; children: ReactNode; wide?: boolean }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function KeepAliveSettings({ settings }: { settings: RuntimeSettingsAccess }) { + const [keepAlive, setKeepAlive] = useState(String(settings.get(KEEP_ALIVE_SETTING) ?? "75s")); + const [reconnect, setReconnect] = useState(settings.get(AUTO_RECONNECT_SETTING) !== false); + return ( + +

{tr("How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.")}

+ { setReconnect(event.target.checked); void settings.set(AUTO_RECONNECT_SETTING, event.target.checked); }} /> + {tr("Reconnect Automatically")} + +
+ ); +} + +export function SavedRuntimes({ connector, current, busy, perform }: { connector: RuntimeConnector; current?: string; busy?: string; perform: Perform }) { + const [targets, setTargets] = useState(); + useEffect(() => { + let live = true; + void connector.recents().then(value => { if (live) setTargets(value); }, () => { if (live) setTargets([]); }); + return () => { live = false; }; + }, [connector, current]); + if (!targets?.length) return null; + return ( + +
    + {targets.map(target => ( +
  • + + {target.name}{target.detail ?? target.url ?? target.path} + {target.key === current ? {tr("In use")} : ( + + )} + {connector.forget && target.key !== current && ( + + )} +
  • + ))} +
+
+ ); +} + +export function DiscoveredRuntimes({ connector, busy, perform, onPair }: { connector: RuntimeConnector; busy?: string; perform: Perform; onPair: (url: string) => void }) { + const [found, setFound] = useState([]); + useEffect(() => connector.discover?.(setFound), [connector]); + return ( + + {found.length ? ( +
    + {found.map(runtime => { + const key = "lan:" + runtime.runtimeId; + return ( +
  • + + {runtime.name}{`${runtime.host}:${runtime.port}${runtime.version ? ` · v${runtime.version}` : ""}`} + +
  • + ); + })} +
+ ) : ( +

+ )} +

{tr("Run oxbit --lan on your computer to list it here.")}

+
+ ); +} + +export function SshServers({ connector, busy, perform }: { connector: RuntimeConnector; busy?: string; perform: Perform }) { + const [hosts, setHosts] = useState(); + const [paths, setPaths] = useState>({}); + useEffect(() => { + let live = true; + void connector.sshHosts?.().then(value => { if (live) setHosts(value); }, () => { if (live) setHosts([]); }); + return () => { live = false; }; + }, [connector]); + return ( + + {hosts?.length ? ( +
    + {hosts.map(host => { + const key = "ssh:" + host.id, path = paths[host.id] ?? host.lastPath ?? "~"; + return ( +
  • + {host.label}{host.detail} + setPaths(current => ({ ...current, [host.id]: event.target.value }))} /> + +
  • + ); + })} +
+ ) : hosts &&

{tr("No SSH hosts saved yet.")}

} + {connector.manageSsh && } +
+ ); +} diff --git a/packages/features/runtime/src/store.ts b/packages/features/runtime/src/store.ts new file mode 100644 index 00000000..298bc2cb --- /dev/null +++ b/packages/features/runtime/src/store.ts @@ -0,0 +1,77 @@ +import { splitRuntimeError, type RuntimeIdentity } from "@oxbit/protocol"; +import type { RuntimeConnector, RuntimeStatus } from "@oxbit/workbench"; + +/** Observable connection status an app keeps across workbench sessions. */ +export class RuntimeStatusStore { + private listeners = new Set<() => void>(); + private value: RuntimeStatus = { state: "disconnected" }; + get = () => this.value; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + set(patch: Partial) { + this.value = { ...this.value, ...patch }; + for (const listener of [...this.listeners]) listener(); + } + replace(next: RuntimeStatus) { + this.value = next; + for (const listener of [...this.listeners]) listener(); + } + progress(message: string) { + this.set({ state: "connecting", progress: message }); + } + fail(error: unknown) { + const { message, detail } = splitRuntimeError(error instanceof Error ? error.message : String(error)); + this.set({ state: "failed", progress: undefined, error: { message, detail, at: Date.now() } }); + } +} + +/** The parts of RuntimeClient the page reads; structural so apps can pass any client. */ +export interface RuntimeClientLike { + url: string; + connected: boolean; + identity?: RuntimeIdentity; + session?: { trusted: boolean; owner: boolean; workspaceName?: string }; +} + +export function clientStatus(client: RuntimeClientLike, kind: RuntimeStatus["kind"], name?: string): Partial { + let url: URL | undefined; + try { url = new URL(client.url); } catch { url = undefined; } + return { + kind, + url: client.url, + host: url?.hostname, + port: url ? Number(url.port || (url.protocol === "https:" ? 443 : 80)) : undefined, + name: client.identity?.name ?? name ?? url?.hostname, + version: client.identity?.version, + startedAt: client.identity?.startedAt, + runtimeId: client.identity?.id, + workspace: client.session?.workspaceName, + trusted: client.session?.trusted, + owner: client.session?.owner, + }; +} + +/** Cloud button: open the page when connected or nothing is known; otherwise connect, opening the page on failure. */ +export async function oneTap(connector: RuntimeConnector | undefined, open: () => void) { + const status = connector?.status(); + const target = connector?.quickTarget(); + if (!connector || !target || status?.state === "connected" || status?.state === "connecting") { + open(); + return; + } + try { + await connector.connect(target); + } catch { + open(); + } +} + +export function formatUptime(startedAt: number | undefined, now = Date.now()) { + if (!startedAt || startedAt > now) return undefined; + const minutes = Math.floor((now - startedAt) / 60_000); + if (minutes < 1) return "<1m"; + const days = Math.floor(minutes / 1440), hours = Math.floor((minutes % 1440) / 60), rest = minutes % 60; + return days ? `${days}d ${hours}h` : hours ? `${hours}h ${rest}m` : `${rest}m`; +} diff --git a/packages/features/runtime/tsconfig.json b/packages/features/runtime/tsconfig.json new file mode 100644 index 00000000..166329e8 --- /dev/null +++ b/packages/features/runtime/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist-types", + "tsBuildInfoFile": "dist-types/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.json" + ], + "exclude": [ + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../../packages/sdk" + }, + { + "path": "../../../packages/protocol" + }, + { + "path": "../../../packages/ui" + }, + { + "path": "../../workbench" + } + ] +} diff --git a/packages/host-desktop/package.json b/packages/host-desktop/package.json index ca69b599..ed97add7 100644 --- a/packages/host-desktop/package.json +++ b/packages/host-desktop/package.json @@ -9,7 +9,10 @@ "@oxbit/app-workbench": "workspace:*", "@oxbit/host-runtime": "workspace:*", "@tauri-apps/api": "2.11.1", - "@oxbit/icon-themes": "workspace:*" + "@oxbit/icon-themes": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", + "@oxbit/protocol": "workspace:*", + "@oxbit/workbench": "workspace:*" }, "license": "MIT" } diff --git a/packages/host-desktop/src/index.ts b/packages/host-desktop/src/index.ts index aa21f017..5358f29e 100644 --- a/packages/host-desktop/src/index.ts +++ b/packages/host-desktop/src/index.ts @@ -15,6 +15,9 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { Persistence } from "@oxbit/sdk"; import { createWorkbenchSession, type Session } from "@oxbit/app-workbench"; import { RuntimeClient, RuntimeFileSystem } from "@oxbit/host-runtime"; +import { KEEP_ALIVE_SETTING, keepAliveMs } from "@oxbit/feature-runtime"; +import { DesktopRuntimeConnector, type DesktopRuntimeHost } from "./runtime-connector.js"; +export { DesktopRuntimeConnector, type DesktopRuntimeHost } from "./runtime-connector.js"; export interface Project { key: string; @@ -65,8 +68,8 @@ export const native = { fileOpened: (key: string, request: string) => invoke("desktop_file_opened", { key, request }), activate: (key: string) => invoke("desktop_activate", { key }), - connection: (key: string, restart = false) => - invoke("desktop_connection", { key, restart }), + connection: (key: string, restart = false, keepAlive?: number) => + invoke("desktop_connection", { key, restart, keepAlive }), move: (key: string) => invoke("desktop_move_project", { key }), close: (kind: CloseRequest["reason"], key?: string) => invoke("desktop_request_close", { kind, key }), @@ -167,6 +170,7 @@ export interface ProjectSession { error?: string; failed?: boolean; loading?: boolean; + connector: DesktopRuntimeConnector; } export interface WindowView { projects: ProjectSession[]; @@ -188,6 +192,8 @@ export class ProjectSessionManager { profile: { user: {}, userLanguages: {} }, loading: true, }; + /** SSH actions the window supplies to every project's Runtime page. */ + ssh: Pick = {}; snapshot = () => this.view; subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -217,7 +223,7 @@ export class ProjectSessionManager { await listen("desktop-changed", () => void this.refresh()), ); this.subscriptions.push( - await listen<{ key: string }>("desktop-runtime-failed", ({ payload }) => { + await listen<{ key: string; detail?: string }>("desktop-runtime-failed", ({ payload }) => { const entry = this.entries.get(payload.key); if (entry) { entry.failed = true; @@ -225,6 +231,7 @@ export class ProjectSessionManager { entry.project.path.startsWith("ssh://") ? "The SSH connection closed. Your drafts are retained. Reconnect to resume editing and tools." : "The project runtime stopped. Your drafts are retained. Restart it to resume tools."; + entry.connector.failed(entry.error, payload.detail || undefined); entry.session?.runtime?.terminated(); void entry.session?.persist(); this.publish(); @@ -265,7 +272,7 @@ export class ProjectSessionManager { for (const project of snapshot.projects) { let entry = this.entries.get(project.key); if (!entry) { - entry = { project }; + entry = { project, connector: this.connector(project.key) }; this.entries.set(project.key, entry); } entry.project = project; @@ -298,6 +305,15 @@ export class ProjectSessionManager { this.queue = next.catch(() => {}); return next; } + private connector(key: string) { + return new DesktopRuntimeConnector(() => this.entries.get(key)!.project, { + restart: () => this.restart(key), + recent: () => this.view.recent, + open: path => native.open(path), + startSsh: (authority, path) => this.ssh.startSsh?.(authority, path) ?? Promise.resolve(), + manageSsh: () => this.ssh.manageSsh?.(), + }); + } private async load(entry: ProjectSession, restart = false) { entry.loading = true; entry.error = undefined; @@ -307,7 +323,8 @@ export class ProjectSessionManager { throw new Error( "This folder is unavailable. Reconnect its disk, then retry. Drafts are retained.", ); - const connection = await native.connection(entry.project.key, restart); + entry.connector.store.progress(entry.project.path.startsWith("ssh://") ? "Connecting over SSH…" : "Starting the runtime…"); + const connection = await native.connection(entry.project.key, restart, keepAliveMs(this.view.profile.user[KEEP_ALIVE_SETTING])); const runtime = new RuntimeClient(connection.url, "default", { token: connection.token, persistToken: false, @@ -326,7 +343,9 @@ export class ProjectSessionManager { additionalExtensionOrigins: ["tauri://localhost"], active: false, protectUnload: false, + runtimeConnector: entry.connector, }); + entry.connector.attach(session); session.workbench.set({ projectName: entry.project.name }); for (const setting of desktopConfiguration) session.kernel.configuration.register(setting); const mirrorProfile = () => { @@ -347,7 +366,8 @@ export class ProjectSessionManager { throw error; } } catch (error) { - entry.error = String(error); + entry.error = String(error).replace(/^Error: /, ""); + entry.connector.failed(entry.error); } finally { entry.loading = false; this.publish(); diff --git a/packages/host-desktop/src/runtime-connector.ts b/packages/host-desktop/src/runtime-connector.ts new file mode 100644 index 00000000..b0641583 --- /dev/null +++ b/packages/host-desktop/src/runtime-connector.ts @@ -0,0 +1,99 @@ +import { RUNTIME_OUTPUT_SEPARATOR, splitRuntimeError } from "@oxbit/protocol"; +import { RuntimeStatusStore, clientStatus } from "@oxbit/feature-runtime"; +import type { RuntimeConnector, RuntimeTarget, SshHostSummary } from "@oxbit/workbench"; +import type { Session } from "@oxbit/app-workbench"; + +export interface DesktopRuntimeHost { + restart(): Promise; + recent(): string[]; + open(path: string): Promise; + /** Opens `ssh://` as a project; authority is `[user@]host[:port]`. */ + startSsh?(authority: string, path: string): Promise; + manageSsh?(): void; +} + +const remoteParts = (uri: string) => { + const match = /^ssh:\/\/([^/]+)(\/.*)?$/.exec(uri); + if (!match) return undefined; + const path = decodeURIComponent(match[2] ?? "/").replace(/^\/~(?=\/|$)/, "~"); + return { authority: match[1], path }; +}; + +/** Follows one desktop project's runtime across restarts; each new session attaches to it. */ +export class DesktopRuntimeConnector implements RuntimeConnector { + readonly store = new RuntimeStatusStore(); + private session?: Session; + private detach: (() => void)[] = []; + constructor(private readonly project: () => { key: string; path: string; name: string }, private readonly host: DesktopRuntimeHost) {} + status = this.store.get; + subscribe = this.store.subscribe; + private get remote() { return remoteParts(this.project().path); } + private get key() { return "project:" + this.project().key; } + + attach(session: Session) { + for (const off of this.detach) off(); + this.session = session; + const runtime = session.runtime; + if (!runtime) return; + const remote = this.remote; + const sync = () => { + const state = runtime.connected ? "connected" : this.store.get().state === "failed" ? "failed" : "reconnecting"; + const status = clientStatus(runtime, remote ? "ssh" : "local"); + this.store.replace({ + ...status, state, targetKey: this.key, error: state === "connected" ? undefined : this.store.get().error, + ...(remote ? { name: remote.authority, host: remote.authority, port: undefined, workspace: remote.path } : {}), + }); + }; + sync(); + this.detach = [ + runtime.subscribe("connection.change", sync), + runtime.subscribe("workspace.trust", ({ trusted }) => this.store.set({ trusted: trusted === true })), + ]; + } + /** Records a runtime that stopped or could not start, keeping its stderr tail as detail. */ + failed(message: string, detail?: string) { + const split = splitRuntimeError(detail ? message + RUNTIME_OUTPUT_SEPARATOR + detail : message); + this.store.replace({ ...this.store.get(), state: "failed", progress: undefined, error: { ...split, at: Date.now() } }); + } + + quickTarget(): RuntimeTarget { + const project = this.project(); + const remote = this.remote; + return { key: this.key, kind: remote ? "ssh" : "local", name: remote?.authority ?? project.name, path: project.path }; + } + async connect(target: RuntimeTarget) { + if (target.key === this.key) return this.restart(); + if (target.path) await this.host.open(target.path); + } + async recents() { + return this.host.recent().flatMap((uri): RuntimeTarget[] => { + const parts = remoteParts(uri); + return parts ? [{ key: "ssh:" + uri, kind: "ssh", name: parts.authority, path: uri, detail: parts.path, hostId: parts.authority }] : []; + }); + } + async restart() { + this.store.set({ state: "connecting", progress: this.remote ? "Reconnecting over SSH…" : "Restarting the runtime…", error: undefined }); + await this.host.restart(); + } + async trust(trusted: boolean) { + const session = this.session; + if (!session?.runtime) return; + await session.runtime.trust(trusted); + this.store.set({ trusted: !!session.runtime.session?.trusted }); + session.kernel.context.set("trusted", !!session.runtime.session?.trusted); + session.workbench.touch(); + } + sshHosts = async (): Promise => { + const hosts = new Map(); + for (const target of await this.recents()) + if (!hosts.has(target.hostId!)) hosts.set(target.hostId!, { id: target.hostId!, label: target.name, detail: target.detail ?? "", lastPath: target.detail }); + return [...hosts.values()]; + }; + get startSsh() { + const start = this.host.startSsh; + return start && ((authority: string, path: string) => start(authority, path)); + } + get manageSsh() { + return this.host.manageSsh; + } +} diff --git a/packages/host-desktop/tsconfig.json b/packages/host-desktop/tsconfig.json index da6dcc08..62c5733a 100644 --- a/packages/host-desktop/tsconfig.json +++ b/packages/host-desktop/tsconfig.json @@ -25,6 +25,15 @@ }, { "path": "../icon-themes" + }, + { + "path": "../features/runtime" + }, + { + "path": "../protocol" + }, + { + "path": "../workbench" } ] } diff --git a/packages/host-ios/src/discovery.test.ts b/packages/host-ios/src/discovery.test.ts new file mode 100644 index 00000000..20307119 --- /dev/null +++ b/packages/host-ios/src/discovery.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) })); +vi.mock("@tauri-apps/api/event", () => ({ listen: async () => () => undefined })); + +const { discovery, DISCOVERY_POLL_MS, native, ssh, rememberWorkspace } = await import("./index.js"); +const DISCOVERY = "plugin:oxbit-files|runtime_discovery"; +const mac = { runtimeId: "runtime-0123", name: "mac", version: "0.5.0", host: "192.168.1.10", port: 9277, url: "http://192.168.1.10:9277" }; +const linux = { runtimeId: "runtime-4567", name: "linux", host: "192.168.1.11", port: 9277, url: "http://192.168.1.11:9277" }; +const operations = () => invoke.mock.calls.filter(([command]) => command === DISCOVERY).map(([, args]) => args.request.operation); + +beforeEach(() => { + invoke.mockReset(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("discovery.watch", () => { + it("starts browsing, polls the list, delivers changes deduplicated by runtime ID, and stops on dispose", async () => { + vi.useFakeTimers(); + const lists = [[mac], [mac, { ...mac, host: "fe80::1", url: "http://[fe80::1]:9277" }], [mac, linux]]; + invoke.mockImplementation(async (command: string, args?: { request: { operation: string } }) => { + if (command !== DISCOVERY) throw new Error(`unexpected ${command}`); + const operation = args!.request.operation; + return { runtimes: operation === "list" ? lists.shift() ?? [mac, linux] : [] }; + }); + const seen: unknown[] = []; + const dispose = discovery.watch((runtimes) => seen.push(runtimes)); + await vi.advanceTimersByTimeAsync(0); + expect(seen).toEqual([[]]); + for (let poll = 0; poll < 4; poll += 1) await vi.advanceTimersByTimeAsync(DISCOVERY_POLL_MS); + expect(seen).toEqual([[], [mac], [mac, linux]]); + dispose(); + dispose(); + await vi.advanceTimersByTimeAsync(DISCOVERY_POLL_MS * 3); + expect(operations()).toEqual(["start", "list", "list", "list", "list", "stop"]); + expect(invoke).toHaveBeenCalledWith(DISCOVERY, { request: { operation: "start" } }); + }); + + it("does not poll when browsing cannot start", async () => { + vi.useFakeTimers(); + invoke.mockRejectedValue(new Error("Files app folders need iOS")); + const listener = vi.fn(); + const dispose = discovery.watch(listener); + await vi.advanceTimersByTimeAsync(DISCOVERY_POLL_MS * 3); + dispose(); + await vi.advanceTimersByTimeAsync(0); + expect(listener).not.toHaveBeenCalled(); + expect(operations()).toEqual(["start", "stop"]); + }); +}); + +describe("runtime bridge", () => { + it("passes the runtime ID and health operation to native credentials", async () => { + const runtime = { id: "runtime-0123", name: "mac", version: "0.5.0", startedAt: 1 }; + invoke.mockResolvedValueOnce({ token: "t", runtime }); + await expect(native.runtimeCredentials({ operation: "pair", url: mac.url, code: "123456" })).resolves.toEqual({ token: "t", runtime }); + invoke.mockResolvedValueOnce({ runtime }); + await expect(native.runtimeCredentials({ operation: "health", url: mac.url })).resolves.toEqual({ runtime }); + invoke.mockResolvedValueOnce({ token: "t" }); + await native.runtimeCredentials({ operation: "get", url: mac.url, runtimeId: runtime.id }); + expect(invoke.mock.calls.map(([command, args]) => [command, args])).toEqual([ + ["plugin:oxbit-files|runtime_credentials", { request: { operation: "pair", url: mac.url, code: "123456" } }], + ["plugin:oxbit-files|runtime_credentials", { request: { operation: "health", url: mac.url } }], + ["plugin:oxbit-files|runtime_credentials", { request: { operation: "get", url: mac.url, runtimeId: runtime.id } }], + ]); + }); + + it("passes keepAlive to the SSH runtime start", async () => { + invoke.mockResolvedValue({ url: "http://127.0.0.1:1", token: "t", workspaceKey: "k", root: "/~" }); + await ssh.runtimeStart("w1", "h1", "~/app", 0); + await ssh.runtimeStart("w2", "h1", "~/app"); + expect(invoke.mock.calls[0]).toEqual(["ios_ssh_runtime_start", { id: "w1", hostId: "h1", path: "~/app", keepAlive: 0 }, undefined]); + expect(invoke.mock.calls[1]![1].keepAlive).toBeUndefined(); + }); +}); + +describe("recents by runtime ID", () => { + const remember = async (stored: unknown[], entry: Parameters[0]) => { + invoke.mockResolvedValueOnce(stored).mockResolvedValueOnce(undefined); + return rememberWorkspace(entry); + }; + + it("replaces an entry for the same runtime at a new port", async () => { + const old = { id: "runtime:http://mac:9277", kind: "runtime" as const, name: "mac", url: "http://mac:9277", runtimeId: "runtime-0123", lastOpened: 1 }; + const other = { id: "runtime:http://linux:9277", kind: "runtime" as const, name: "linux", url: "http://linux:9277", runtimeId: "runtime-4567", lastOpened: 2 }; + const next = await remember([old, other], { id: "runtime:http://mac:9300", kind: "runtime", name: "mac", url: "http://mac:9300", runtimeId: "runtime-0123" }); + expect(next.map((item) => item.id)).toEqual(["runtime:http://mac:9300", "runtime:http://linux:9277"]); + }); + + it("keeps entries without a runtime ID apart", async () => { + const legacy = [ + { id: "runtime:http://a:1", kind: "runtime" as const, name: "a", url: "http://a:1", lastOpened: 1 }, + { id: "runtime:http://b:1", kind: "runtime" as const, name: "b", url: "http://b:1", lastOpened: 2 }, + ]; + const next = await remember(legacy, { id: "runtime:http://c:1", kind: "runtime", name: "c", url: "http://c:1" }); + expect(next).toHaveLength(3); + }); +}); diff --git a/packages/host-ios/src/discovery.ts b/packages/host-ios/src/discovery.ts new file mode 100644 index 00000000..66937779 --- /dev/null +++ b/packages/host-ios/src/discovery.ts @@ -0,0 +1,58 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** An Oxbit runtime advertised over Bonjour as `_oxbit._tcp` on the local network. */ +export interface DiscoveredRuntime { + runtimeId: string; + name: string; + version?: string; + host: string; + port: number; + url: string; +} +export const DISCOVERY_POLL_MS = 2000; + +const request = (operation: "start" | "list" | "stop") => + invoke<{ runtimes: DiscoveredRuntime[] }>("plugin:oxbit-files|runtime_discovery", { request: { operation } }); + +function unique(runtimes: DiscoveredRuntime[]): DiscoveredRuntime[] { + const byId = new Map(); + for (const runtime of runtimes) if (!byId.has(runtime.runtimeId)) byId.set(runtime.runtimeId, runtime); + return [...byId.values()]; +} + +/** Native browsing stops two minutes after the last `start` or `list`, so watching polls `list`. */ +export const discovery = { + watch(listener: (runtimes: DiscoveredRuntime[]) => void): () => void { + let active = true; + let last: string | undefined; + let timer: ReturnType | undefined; + const deliver = ({ runtimes }: { runtimes: DiscoveredRuntime[] }) => { + if (!active) return; + const next = unique(Array.isArray(runtimes) ? runtimes : []); + const key = JSON.stringify(next); + if (key === last) return; + last = key; + listener(next); + }; + const poll = () => { + timer = setTimeout(() => { + void request("list").then(deliver, () => undefined).finally(() => { + if (active) poll(); + }); + }, DISCOVERY_POLL_MS); + }; + void request("start").then( + (result) => { + deliver(result); + if (active) poll(); + }, + () => undefined, + ); + return () => { + if (!active) return; + active = false; + clearTimeout(timer); + void request("stop").catch(() => undefined); + }; + }, +}; diff --git a/packages/host-ios/src/index.ts b/packages/host-ios/src/index.ts index 26fc323f..da271777 100644 --- a/packages/host-ios/src/index.ts +++ b/packages/host-ios/src/index.ts @@ -9,6 +9,7 @@ export { type GitAccount, type GitCredentials, type GitCredentialRequest, + type RuntimeCredentialRequest, GIT_ACCOUNT_KEY, GIT_SSH_KEY, type CommitSigningKey, @@ -33,4 +34,5 @@ export { IosFileSystem, SshFileSystem, revisionOf } from "./filesystem.js"; export { IosPersistence, PROFILE_SCOPE, SESSION_SCOPE } from "./persistence.js"; export { IosIconPackStore } from "./icon-packs.js"; export { IosLanguageTransport, createIosLanguageFeature, type IosLanguageServerKind } from "./language.js"; +export { discovery, DISCOVERY_POLL_MS, type DiscoveredRuntime } from "./discovery.js"; export { loadRecents, rememberWorkspace, forgetWorkspace, RECENTS_LIMIT, type RecentWorkspace } from "./workspaces.js"; diff --git a/packages/host-ios/src/native.ts b/packages/host-ios/src/native.ts index 34fb275b..a89a7907 100644 --- a/packages/host-ios/src/native.ts +++ b/packages/host-ios/src/native.ts @@ -1,6 +1,6 @@ import { invoke, type InvokeArgs, type InvokeOptions } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { RpcError } from "@oxbit/protocol"; +import { RpcError, type RuntimeIdentity } from "@oxbit/protocol"; import type { FileEntry } from "@oxbit/sdk"; export interface NativeError { @@ -97,6 +97,13 @@ export type GitCredentialRequest = | { operation: "addGitHub"; token: string } | { operation: "addGitea"; url: string; token: string } | { operation: "remove" | "setDefault"; id: string }; +export interface RuntimeCredentialRequest { + operation: "get" | "pair" | "forget" | "health"; + url: string; + /** Keys the Keychain item by runtime ID; a saved URL item moves to it on `get`. */ + runtimeId?: string; + code?: string; +} /** Workspace storage key holding the account ID that native Git uses for the workspace root. */ export const GIT_ACCOUNT_KEY = "git-account"; /** Workspace storage key holding the SSH key ID that native Git uses for SSH remotes. */ @@ -215,8 +222,9 @@ export const ssh = { cancelTransfer: (transferId: string) => call("ios_ssh_transfer_cancel", { transferId }), onTransfer: (transferId: string, listener: (progress: TransferProgress) => void) => listen(`ios-ssh-transfer:${transferId}`, (event) => listener(event.payload)), - runtimeStart: (id: string, hostId: string, path: string) => - call("ios_ssh_runtime_start", { id, hostId, path }), + /** `keepAlive` is milliseconds after the last client; 0 runs until stopped; omitted uses 75000. */ + runtimeStart: (id: string, hostId: string, path: string, keepAlive?: number) => + call("ios_ssh_runtime_start", { id, hostId, path, keepAlive }), runtimeResume: (id: string) => call("ios_ssh_runtime_resume", { id }), runtimeStop: (id: string) => call("ios_ssh_runtime_stop", { id }), onRuntime: (id: string, listener: (event: RemoteRuntimeEvent) => void) => @@ -234,8 +242,9 @@ export const native = { gitRequest: (id: string, requestId: string, method: string, params: Record) => call("ios_git_request", { id, requestId, method, params }), gitCancel: (id: string, requestId: string) => call("ios_git_cancel", { id, requestId }), - runtimeCredentials: (request: { operation: "get" | "pair" | "forget"; url: string; code?: string }) => - call<{ token?: string }>("plugin:oxbit-files|runtime_credentials", { request }), + /** `health` reads `/api/health` natively; `pair` returns the runtime identity when it reports one. */ + runtimeCredentials: (request: RuntimeCredentialRequest) => + call<{ token?: string; runtime?: RuntimeIdentity }>("plugin:oxbit-files|runtime_credentials", { request }), storageGet: (scope: string, key: string) => call("ios_storage_get", { scope, key }), storageSet: (scope: string, key: string, value: unknown) => call("ios_storage_set", { scope, key, value: value ?? null }), diff --git a/packages/host-ios/src/workspaces.ts b/packages/host-ios/src/workspaces.ts index b5bec37f..1387e4eb 100644 --- a/packages/host-ios/src/workspaces.ts +++ b/packages/host-ios/src/workspaces.ts @@ -10,6 +10,8 @@ export interface RecentWorkspace { directory?: string; hostId?: string; remotePath?: string; + /** The runtime's identity `id`; one recent per runtime survives a port change. */ + runtimeId?: string; lastOpened: number; } const RECENTS_KEY = "recents"; @@ -20,7 +22,9 @@ export async function loadRecents(): Promise { return Array.isArray(stored) ? stored : []; } export async function rememberWorkspace(entry: Omit): Promise { - const next = [{ ...entry, lastOpened: Date.now() }, ...(await loadRecents()).filter((item) => item.id !== entry.id)] + const replaced = (item: RecentWorkspace) => + item.id === entry.id || (entry.runtimeId !== undefined && item.runtimeId === entry.runtimeId); + const next = [{ ...entry, lastOpened: Date.now() }, ...(await loadRecents()).filter((item) => !replaced(item))] .sort((a, b) => b.lastOpened - a.lastOpened) .slice(0, RECENTS_LIMIT); await native.storageSet(SESSION_SCOPE, RECENTS_KEY, next); diff --git a/packages/host-runtime/src/index.test.ts b/packages/host-runtime/src/index.test.ts index 52912e80..188f8777 100644 --- a/packages/host-runtime/src/index.test.ts +++ b/packages/host-runtime/src/index.test.ts @@ -78,3 +78,42 @@ describe('desktop credentials and recovery identity', () => { a.dispose(); b.dispose(); }); }); + +describe('runtime identity', () => { + class IdentitySocket extends Socket { + send(raw: string) { + const message = JSON.parse(raw); this.sent.push(message); + if (message.method === 'auth.authenticate') queueMicrotask(() => this.receive({ v: 1, type: 'response', id: message.id, result: { workspaceId: 'default', trusted: false, owner: true, capabilities: [], runtime: { id: 'runtime-1234', name: 'studio', version: '0.4.2', startedAt: 5 } } })); + } + } + it('moves a URL-keyed token to the runtime id so a new port finds it', async () => { + const store = new Map([['oxbit.runtime.token:http://10.0.0.4:50001', 'saved-token']]); + vi.stubGlobal('sessionStorage', { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => store.set(key, value), removeItem: (key: string) => store.delete(key) }); + vi.stubGlobal('WebSocket', IdentitySocket); + const first = new RuntimeClient('http://10.0.0.4:50001'); + await first.connect(); + expect(first.identity).toMatchObject({ id: 'runtime-1234', name: 'studio' }); + expect(store.get('oxbit.runtime.token:id:runtime-1234')).toBe('saved-token'); + expect(store.has('oxbit.runtime.token:http://10.0.0.4:50001')).toBe(false); + first.dispose(); + const moved = new RuntimeClient('http://10.0.0.4:50999', 'default', { runtimeId: 'runtime-1234' }); + await moved.connect(); + expect(Socket.instances.at(-1)!.sent[0].params.token).toBe('saved-token'); + moved.dispose(); + }); + it('stays disconnected after a drop when automatic reconnects are off', async () => { + vi.useFakeTimers(); vi.stubGlobal('WebSocket', Socket); + const client = new RuntimeClient('http://runtime.test', 'default', { persistToken: false }); + await client.connect(); + client.autoReconnect = false; + const states: string[] = []; + client.subscribe('connection.change', ({ state }) => states.push(state)); + Socket.instances[0].close(); + await vi.advanceTimersByTimeAsync(20000); + expect(states).toEqual(['disconnected']); + expect(Socket.instances).toHaveLength(1); + await client.reconnect(); + expect(client.connected).toBe(true); + client.dispose(); + }); +}); diff --git a/packages/host-runtime/src/index.ts b/packages/host-runtime/src/index.ts index ce1d72ce..7763f024 100644 --- a/packages/host-runtime/src/index.ts +++ b/packages/host-runtime/src/index.ts @@ -7,7 +7,8 @@ import type { FileChange, Disposable, } from "@oxbit/sdk"; -import { RpcError, MAX_MESSAGE_BYTES, MAX_BUFFER_BYTES, READ_CHUNK_BYTES, operationMethods, type ServerMessage } from "@oxbit/protocol"; +import { RpcError, MAX_MESSAGE_BYTES, MAX_BUFFER_BYTES, READ_CHUNK_BYTES, operationMethods, isRuntimeIdentity, type RuntimeIdentity, type ServerMessage } from "@oxbit/protocol"; +const TOKEN_KEY = "oxbit.runtime.token:"; type Pending = { resolve: (v: any) => void; reject: (e: unknown) => void; @@ -18,6 +19,8 @@ export interface RuntimeClientOptions { token?: string; /** Desktop credentials never touch WebView storage. Browser pairing keeps its default. */ persistToken?: boolean; + /** Reads the token saved for this runtime id before the URL-keyed one. */ + runtimeId?: string; } export class RuntimeClient implements RpcClient { private socket?: WebSocket; @@ -32,6 +35,9 @@ export class RuntimeClient implements RpcClient { private recoveringOperations = false; private opening?: Promise; connected = false; + /** When false, a dropped connection stays disconnected until connect() runs again. */ + autoReconnect = true; + identity?: RuntimeIdentity; session?: { token: string; workspaceId: string; @@ -48,13 +54,22 @@ export class RuntimeClient implements RpcClient { private readonly options: RuntimeClientOptions = {}, ) { this.url = url.replace(/\/$/, ""); - this.token = options.token ?? (options.persistToken === false ? undefined : - globalThis.sessionStorage?.getItem("oxbit.runtime.token:" + this.url) ?? undefined); + const storage = options.persistToken === false ? undefined : globalThis.sessionStorage; + this.token = options.token ?? (options.runtimeId ? storage?.getItem(TOKEN_KEY + "id:" + options.runtimeId) : undefined) ?? + storage?.getItem(TOKEN_KEY + this.url) ?? undefined; } private rememberToken(token: string) { this.token = token; - if (this.options.persistToken !== false) - globalThis.sessionStorage?.setItem("oxbit.runtime.token:" + this.url, token); + this.persistToken(); + } + /** Moves a URL-keyed token to the runtime id, so a new port finds it. */ + private persistToken() { + const storage = this.options.persistToken === false ? undefined : globalThis.sessionStorage; + if (!storage || !this.token) return; + if (this.identity) { + storage.setItem(TOKEN_KEY + "id:" + this.identity.id, this.token); + storage.removeItem(TOKEN_KEY + this.url); + } else storage.setItem(TOKEN_KEY + this.url, this.token); } async pair(code: string) { if (code.startsWith("grant:")) { @@ -72,6 +87,7 @@ export class RuntimeClient implements RpcClient { if (!r.ok) throw new Error(data.error?.message ?? data.error ?? "Pairing failed"); this.session = data; + if (isRuntimeIdentity(data.runtime)) this.identity = data.runtime; this.rememberToken(data.token); return data; } @@ -101,6 +117,10 @@ export class RuntimeClient implements RpcClient { clearTimeout(timer); this.connected = true; this.retries = 0; + if (isRuntimeIdentity(session.runtime)) { + this.identity = session.runtime; + this.persistToken(); + } this.session = { token: this.token ?? "", workspaceId: String(session.workspaceId ?? this.workspaceId), @@ -168,7 +188,7 @@ export class RuntimeClient implements RpcClient { ws.onclose = (event) => { clearTimeout(timer); if (this.socket !== ws) return; - if (event.code === 4003) this.stopped = true; + if (event.code === 4003 || !this.autoReconnect) this.stopped = true; this.connected = false; this.opening = undefined; for (const [id, p] of this.pending) { @@ -307,6 +327,11 @@ export class RuntimeClient implements RpcClient { } } } + /** Skips the backoff wait, for a return to the foreground or a user retry. */ + reconnect() { + this.retries = 0; + return this.connected ? Promise.resolve() : this.connect(); + } disconnect() { this.stopped = true; clearTimeout(this.retry); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index fdf06290..adc27036 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -3,6 +3,32 @@ export const MAX_MESSAGE_BYTES = 2 * 1024 * 1024; export const MAX_BUFFER_BYTES = 1024 * 1024; /** Base64 of one chunk plus envelope stays below MAX_BUFFER_BYTES, so fs.readBytes never trips the slow-consumer close. */ export const READ_CHUNK_BYTES = 512 * 1024; +/** Mirrored in the Bonjour `_oxbit._tcp` TXT record. */ +export const RUNTIME_SERVICE_TYPE = "_oxbit._tcp"; +/** Served by `/api/health`, and as `runtime` in the `/api/pair` and `auth.authenticate` results. */ +export interface RuntimeIdentity { + /** Persisted in the runtime data directory, so it survives restarts and port changes. */ + id: string; + /** Host name of the machine serving the runtime. */ + name: string; + version: string; + /** Epoch milliseconds when this process started listening. */ + startedAt: number; +} +export function isRuntimeIdentity(value: unknown): value is RuntimeIdentity { + const identity = value as Partial | null; + return typeof identity?.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(identity.id) && + typeof identity.name === "string" && typeof identity.version === "string" && + typeof identity.startedAt === "number"; +} +/** Milliseconds a launched runtime keeps running after its last client and heartbeat; 0 runs until stopped. */ +export const DEFAULT_KEEP_ALIVE_MS = 75_000; +/** Separates a startup error message from the runtime output tail appended to it. */ +export const RUNTIME_OUTPUT_SEPARATOR = "\n--- runtime output ---\n"; +export function splitRuntimeError(text: string): { message: string; detail?: string } { + const index = text.indexOf(RUNTIME_OUTPUT_SEPARATOR); + return index === -1 ? { message: text } : { message: text.slice(0, index), detail: text.slice(index + RUNTIME_OUTPUT_SEPARATOR.length) }; +} export type ClientMessage = | { v: 1; diff --git a/packages/sdk/src/language-servers.ts b/packages/sdk/src/language-servers.ts index a0d518aa..f2f7f692 100644 --- a/packages/sdk/src/language-servers.ts +++ b/packages/sdk/src/language-servers.ts @@ -25,7 +25,7 @@ export interface LanguageServerDefinition extends LanguageServerConfiguration { export interface LanguageServerInstallation { id: string; version: string; - platform: "darwin-arm64" | "linux-x64"; + platform: "darwin-arm64" | "linux-x64" | "linux-arm64"; directory: string; integrity: string; } diff --git a/packages/sdk/src/settings.schema.json b/packages/sdk/src/settings.schema.json index 893e9e93..926a1d6b 100644 --- a/packages/sdk/src/settings.schema.json +++ b/packages/sdk/src/settings.schema.json @@ -65,6 +65,25 @@ "emacs" ] }, + "runtime.keepAlive": { + "title": "Keep Runtime Alive", + "description": "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + "type": "string", + "default": "75s", + "enum": [ + "75s", + "15m", + "1h", + "8h", + "untilStopped" + ] + }, + "runtime.autoReconnect": { + "title": "Reconnect Automatically", + "description": "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.", + "type": "boolean", + "default": true + }, "workbench.tooltipDelay": { "title": "Tooltip Delay", "description": "Delay in milliseconds before showing a tooltip on hover. Keyboard focus shows tooltips immediately.", @@ -690,6 +709,12 @@ "workbench.keymap": { "$ref": "#/properties/workbench.keymap" }, + "runtime.keepAlive": { + "$ref": "#/properties/runtime.keepAlive" + }, + "runtime.autoReconnect": { + "$ref": "#/properties/runtime.autoReconnect" + }, "workbench.tooltipDelay": { "$ref": "#/properties/workbench.tooltipDelay" }, diff --git a/packages/ui/src/locales/de.json b/packages/ui/src/locales/de.json index 71471920..a217bfed 100644 --- a/packages/ui/src/locales/de.json +++ b/packages/ui/src/locales/de.json @@ -780,5 +780,59 @@ "de": "Deutsch", "es": "Spanisch", "ja": "Japanisch", - "zh": "Chinesisch (vereinfacht)" + "zh": "Chinesisch (vereinfacht)", + "Host": "Host", + "Port": "Port", + "Version": "Version", + "Uptime": "Laufzeit", + "Runtime": "Runtime", + "Done": "Fertig", + "No runtime connected": "Keine Runtime verbunden", + "Restarting…": "Wird neu gestartet…", + "Restart": "Neu starten", + "Connect to {0}": "Mit {0} verbinden", + "Pair a runtime": "Runtime koppeln", + "Workspace trust": "Vertrauen des Arbeitsbereichs", + "Only the workspace owner can change trust.": "Nur der Besitzer des Arbeitsbereichs kann das Vertrauen ändern.", + "Pair with a runtime": "Mit einer Runtime koppeln", + "Start a runtime on your computer, then enter its address and owner pairing code.": "Starten Sie eine Runtime auf Ihrem Computer und geben Sie dann ihre Adresse und den Besitzer-Kopplungscode ein.", + "Keep alive": "Aktiv halten", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "Wie lange eine von Oxbit gestartete Runtime weiterläuft, nachdem sich der letzte Client getrennt hat. Terminals und Aufgaben laufen in dieser Zeit weiter.", + "Keep Runtime Alive": "Runtime aktiv halten", + "Reconnect Automatically": "Automatisch neu verbinden", + "Saved runtimes": "Gespeicherte Runtimes", + "Forget {0}": "{0} vergessen", + "In use": "Aktiv", + "On this network": "In diesem Netzwerk", + "Pair": "Koppeln", + "Looking for runtimes on this network…": "Runtimes in diesem Netzwerk werden gesucht…", + "Run oxbit --lan on your computer to list it here.": "Führen Sie oxbit --lan auf Ihrem Computer aus, damit er hier erscheint.", + "SSH servers": "SSH-Server", + "Folder on {0}": "Ordner auf {0}", + "Starting…": "Wird gestartet…", + "Start Oxbit on this server": "Oxbit auf diesem Server starten", + "No SSH hosts saved yet.": "Noch keine SSH-Hosts gespeichert.", + "Manage SSH hosts…": "SSH-Hosts verwalten…", + "This window has no runtime connection.": "Dieses Fenster hat keine Runtime-Verbindung.", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} antwortete mit HTTP {1}; das ist keine Oxbit-Runtime.", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "Die Runtime unter {0} läuft, hat diesen Browser aber abgelehnt. Koppeln Sie sie erneut mit ihrem Kopplungscode.", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} ist erreichbar, hat diese Seite aber nicht akzeptiert. Öffnen Sie Oxbit über die Adresse, die die Runtime ausgibt, oder koppeln Sie sie erneut.", + "No runtime answered at {0}. Start one with oxbit and check the address.": "Unter {0} hat keine Runtime geantwortet. Starten Sie eine mit oxbit und prüfen Sie die Adresse.", + "Connecting to {0}…": "Verbindung mit {0} wird hergestellt…", + "Opening the workspace…": "Arbeitsbereich wird geöffnet…", + "This runtime has no address. Pair it again.": "Diese Runtime hat keine Adresse. Koppeln Sie sie erneut.", + "Enter the runtime's http:// or https:// address.": "Geben Sie die http://- oder https://-Adresse der Runtime ein.", + "Could not reconnect to the runtime: {0}": "Erneute Verbindung zur Runtime fehlgeschlagen: {0}", + "Open Runtime": "Runtime öffnen", + "Reconnecting…": "Verbindung wird wiederhergestellt…", + "Not connected": "Nicht verbunden", + "Connect": "Verbinden", + "Connection failed": "Verbindung fehlgeschlagen", + "75s": "75 Sekunden", + "15m": "15 Minuten", + "1h": "1 Stunde", + "8h": "8 Stunden", + "untilStopped": "Bis zum Beenden", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "Wie lange eine von Oxbit gestartete Runtime weiterläuft, nachdem sich der letzte Client getrennt hat oder ihre Verbindungssignale ausbleiben. Terminals und Aufgaben laufen in dieser Zeit weiter.", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "Nach einem Verbindungsabbruch erneut mit der Runtime verbinden, auch wenn die iOS-App in den Vordergrund zurückkehrt." } diff --git a/packages/ui/src/locales/en.json b/packages/ui/src/locales/en.json index 46164a89..c03819d2 100644 --- a/packages/ui/src/locales/en.json +++ b/packages/ui/src/locales/en.json @@ -808,5 +808,57 @@ "de": "Deutsch", "es": "Spanish", "ja": "Japanese", - "zh": "Chinese (Simplified)" + "zh": "Chinese (Simplified)", + "Host": "Host", + "Port": "Port", + "Version": "Version", + "Uptime": "Uptime", + "Runtime": "Runtime", + "Done": "Done", + "No runtime connected": "No runtime connected", + "Restarting…": "Restarting…", + "Restart": "Restart", + "Connect to {0}": "Connect to {0}", + "Pair a runtime": "Pair a runtime", + "Workspace trust": "Workspace trust", + "Only the workspace owner can change trust.": "Only the workspace owner can change trust.", + "Pair with a runtime": "Pair with a runtime", + "Start a runtime on your computer, then enter its address and owner pairing code.": "Start a runtime on your computer, then enter its address and owner pairing code.", + "Keep alive": "Keep alive", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.", + "Keep Runtime Alive": "Keep Runtime Alive", + "Reconnect Automatically": "Reconnect Automatically", + "Saved runtimes": "Saved runtimes", + "Forget {0}": "Forget {0}", + "In use": "In use", + "On this network": "On this network", + "Pair": "Pair", + "Looking for runtimes on this network…": "Looking for runtimes on this network…", + "Run oxbit --lan on your computer to list it here.": "Run oxbit --lan on your computer to list it here.", + "SSH servers": "SSH servers", + "Folder on {0}": "Folder on {0}", + "Starting…": "Starting…", + "Start Oxbit on this server": "Start Oxbit on this server", + "No SSH hosts saved yet.": "No SSH hosts saved yet.", + "Manage SSH hosts…": "Manage SSH hosts…", + "This window has no runtime connection.": "This window has no runtime connection.", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} answered HTTP {1}; it is not an Oxbit runtime.", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.", + "No runtime answered at {0}. Start one with oxbit and check the address.": "No runtime answered at {0}. Start one with oxbit and check the address.", + "Connecting to {0}…": "Connecting to {0}…", + "Opening the workspace…": "Opening the workspace…", + "This runtime has no address. Pair it again.": "This runtime has no address. Pair it again.", + "Enter the runtime's http:// or https:// address.": "Enter the runtime's http:// or https:// address.", + "Could not reconnect to the runtime: {0}": "Could not reconnect to the runtime: {0}", + "Open Runtime": "Open Runtime", + "Reconnecting…": "Reconnecting…", + "Not connected": "Not connected", + "75s": "75 seconds", + "15m": "15 minutes", + "1h": "1 hour", + "8h": "8 hours", + "untilStopped": "Until stopped", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground." } diff --git a/packages/ui/src/locales/es.json b/packages/ui/src/locales/es.json index bc31d33c..d763d79c 100644 --- a/packages/ui/src/locales/es.json +++ b/packages/ui/src/locales/es.json @@ -808,5 +808,57 @@ "de": "Alemán", "es": "Español", "ja": "Japonés", - "zh": "Chino (simplificado)" + "zh": "Chino (simplificado)", + "Host": "Host", + "Port": "Puerto", + "Version": "Versión", + "Uptime": "Tiempo activo", + "Runtime": "Runtime", + "Done": "Listo", + "No runtime connected": "Ningún runtime conectado", + "Restarting…": "Reiniciando…", + "Restart": "Reiniciar", + "Connect to {0}": "Conectar a {0}", + "Pair a runtime": "Vincular un runtime", + "Workspace trust": "Confianza del espacio de trabajo", + "Only the workspace owner can change trust.": "Solo el propietario del espacio de trabajo puede cambiar la confianza.", + "Pair with a runtime": "Vincular con un runtime", + "Start a runtime on your computer, then enter its address and owner pairing code.": "Inicia un runtime en tu ordenador y escribe su dirección y el código de vinculación del propietario.", + "Keep alive": "Mantener activo", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "Cuánto tiempo sigue en marcha un runtime iniciado por Oxbit después de que se desconecte el último cliente. Los terminales y las tareas continúan mientras se ejecuta.", + "Keep Runtime Alive": "Mantener el runtime activo", + "Reconnect Automatically": "Reconectar automáticamente", + "Saved runtimes": "Runtimes guardados", + "Forget {0}": "Olvidar {0}", + "In use": "En uso", + "On this network": "En esta red", + "Pair": "Vincular", + "Looking for runtimes on this network…": "Buscando runtimes en esta red…", + "Run oxbit --lan on your computer to list it here.": "Ejecuta oxbit --lan en tu ordenador para que aparezca aquí.", + "SSH servers": "Servidores SSH", + "Folder on {0}": "Carpeta en {0}", + "Starting…": "Iniciando…", + "Start Oxbit on this server": "Iniciar Oxbit en este servidor", + "No SSH hosts saved yet.": "Aún no hay hosts SSH guardados.", + "Manage SSH hosts…": "Gestionar hosts SSH…", + "This window has no runtime connection.": "Esta ventana no tiene conexión con un runtime.", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} respondió HTTP {1}; no es un runtime de Oxbit.", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "El runtime en {0} está en marcha pero rechazó este navegador. Vincúlalo de nuevo con su código.", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} es accesible pero no aceptó esta página. Abre Oxbit desde la dirección que muestra el runtime o vuelve a vincularlo.", + "No runtime answered at {0}. Start one with oxbit and check the address.": "Ningún runtime respondió en {0}. Inicia uno con oxbit y comprueba la dirección.", + "Connecting to {0}…": "Conectando a {0}…", + "Opening the workspace…": "Abriendo el espacio de trabajo…", + "This runtime has no address. Pair it again.": "Este runtime no tiene dirección. Vincúlalo de nuevo.", + "Enter the runtime's http:// or https:// address.": "Escribe la dirección http:// o https:// del runtime.", + "Could not reconnect to the runtime: {0}": "No se pudo volver a conectar al runtime: {0}", + "Open Runtime": "Abrir runtime", + "Reconnecting…": "Reconectando…", + "Not connected": "Sin conexión", + "75s": "75 segundos", + "15m": "15 minutos", + "1h": "1 hora", + "8h": "8 horas", + "untilStopped": "Hasta que se detenga", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "Cuánto tiempo sigue en marcha un runtime iniciado por Oxbit después de que se desconecte el último cliente o se detengan las señales de su conexión. Los terminales y las tareas continúan mientras se ejecuta.", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "Volver a conectar al runtime cuando se pierde la conexión, también cuando la app de iOS vuelve al primer plano." } diff --git a/packages/ui/src/locales/ja.json b/packages/ui/src/locales/ja.json index 845d1ead..2b1821bb 100644 --- a/packages/ui/src/locales/ja.json +++ b/packages/ui/src/locales/ja.json @@ -808,5 +808,57 @@ "de": "ドイツ語", "es": "スペイン語", "ja": "日本語", - "zh": "中国語(簡体字)" + "zh": "中国語(簡体字)", + "Host": "ホスト", + "Port": "ポート", + "Version": "バージョン", + "Uptime": "稼働時間", + "Runtime": "ランタイム", + "Done": "完了", + "No runtime connected": "接続中のランタイムはありません", + "Restarting…": "再起動中…", + "Restart": "再起動", + "Connect to {0}": "{0} に接続", + "Pair a runtime": "ランタイムをペアリング", + "Workspace trust": "ワークスペースの信頼", + "Only the workspace owner can change trust.": "信頼を変更できるのはワークスペースの所有者だけです。", + "Pair with a runtime": "ランタイムとペアリング", + "Start a runtime on your computer, then enter its address and owner pairing code.": "コンピュータでランタイムを起動し、そのアドレスと所有者のペアリング コードを入力します。", + "Keep alive": "稼働の維持", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "最後のクライアントが切断された後、Oxbit が起動したランタイムを動かし続ける時間です。その間、ターミナルとタスクは実行を続けます。", + "Keep Runtime Alive": "ランタイムの稼働を維持", + "Reconnect Automatically": "自動的に再接続", + "Saved runtimes": "保存済みのランタイム", + "Forget {0}": "{0} を削除", + "In use": "使用中", + "On this network": "このネットワーク上", + "Pair": "ペアリング", + "Looking for runtimes on this network…": "このネットワーク上のランタイムを検索中…", + "Run oxbit --lan on your computer to list it here.": "コンピュータで oxbit --lan を実行すると、ここに表示されます。", + "SSH servers": "SSH サーバー", + "Folder on {0}": "{0} 上のフォルダー", + "Starting…": "起動中…", + "Start Oxbit on this server": "このサーバーで Oxbit を起動", + "No SSH hosts saved yet.": "保存された SSH ホストはまだありません。", + "Manage SSH hosts…": "SSH ホストを管理…", + "This window has no runtime connection.": "このウィンドウにはランタイム接続がありません。", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} は HTTP {1} を返しました。Oxbit ランタイムではありません。", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "{0} のランタイムは動作していますが、このブラウザーを拒否しました。ペアリング コードでもう一度ペアリングしてください。", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} には到達できますが、このページは受け入れられませんでした。ランタイムが表示するアドレスから Oxbit を開くか、もう一度ペアリングしてください。", + "No runtime answered at {0}. Start one with oxbit and check the address.": "{0} でランタイムが応答しませんでした。oxbit で起動し、アドレスを確認してください。", + "Connecting to {0}…": "{0} に接続中…", + "Opening the workspace…": "ワークスペースを開いています…", + "This runtime has no address. Pair it again.": "このランタイムにはアドレスがありません。もう一度ペアリングしてください。", + "Enter the runtime's http:// or https:// address.": "ランタイムの http:// または https:// アドレスを入力してください。", + "Could not reconnect to the runtime: {0}": "ランタイムに再接続できませんでした: {0}", + "Open Runtime": "ランタイムを開く", + "Reconnecting…": "再接続中…", + "Not connected": "未接続", + "75s": "75 秒", + "15m": "15 分", + "1h": "1 時間", + "8h": "8 時間", + "untilStopped": "停止するまで", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "最後のクライアントが切断されるか接続のハートビートが止まった後、Oxbit が起動したランタイムを動かし続ける時間です。その間、ターミナルとタスクは実行を続けます。", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "接続が切れた後、iOS アプリがフォアグラウンドに戻ったときも含めてランタイムに再接続します。" } diff --git a/packages/ui/src/locales/zh.json b/packages/ui/src/locales/zh.json index aa837bce..acffca09 100644 --- a/packages/ui/src/locales/zh.json +++ b/packages/ui/src/locales/zh.json @@ -808,5 +808,57 @@ "de": "德语", "es": "西班牙语", "ja": "日语", - "zh": "简体中文" + "zh": "简体中文", + "Host": "主机", + "Port": "端口", + "Version": "版本", + "Uptime": "运行时长", + "Runtime": "运行时", + "Done": "完成", + "No runtime connected": "未连接运行时", + "Restarting…": "正在重启…", + "Restart": "重启", + "Connect to {0}": "连接到 {0}", + "Pair a runtime": "配对运行时", + "Workspace trust": "工作区信任", + "Only the workspace owner can change trust.": "只有工作区所有者可以更改信任设置。", + "Pair with a runtime": "与运行时配对", + "Start a runtime on your computer, then enter its address and owner pairing code.": "在电脑上启动运行时,然后输入其地址和所有者配对码。", + "Keep alive": "保持运行", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "最后一个客户端断开后,由 Oxbit 启动的运行时继续运行的时长。运行期间终端和任务会继续执行。", + "Keep Runtime Alive": "保持运行时运行", + "Reconnect Automatically": "自动重新连接", + "Saved runtimes": "已保存的运行时", + "Forget {0}": "忘记 {0}", + "In use": "使用中", + "On this network": "此网络中", + "Pair": "配对", + "Looking for runtimes on this network…": "正在查找此网络中的运行时…", + "Run oxbit --lan on your computer to list it here.": "在电脑上运行 oxbit --lan 即可在此列出。", + "SSH servers": "SSH 服务器", + "Folder on {0}": "{0} 上的文件夹", + "Starting…": "正在启动…", + "Start Oxbit on this server": "在此服务器上启动 Oxbit", + "No SSH hosts saved yet.": "尚未保存 SSH 主机。", + "Manage SSH hosts…": "管理 SSH 主机…", + "This window has no runtime connection.": "此窗口没有运行时连接。", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} 返回了 HTTP {1};它不是 Oxbit 运行时。", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "{0} 上的运行时正在运行,但拒绝了此浏览器。请使用配对码重新配对。", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} 可以访问,但未接受此页面。请从运行时显示的地址打开 Oxbit,或重新配对。", + "No runtime answered at {0}. Start one with oxbit and check the address.": "{0} 没有运行时响应。请用 oxbit 启动一个并检查地址。", + "Connecting to {0}…": "正在连接到 {0}…", + "Opening the workspace…": "正在打开工作区…", + "This runtime has no address. Pair it again.": "此运行时没有地址。请重新配对。", + "Enter the runtime's http:// or https:// address.": "请输入运行时的 http:// 或 https:// 地址。", + "Could not reconnect to the runtime: {0}": "无法重新连接到运行时:{0}", + "Open Runtime": "打开运行时", + "Reconnecting…": "正在重新连接…", + "Not connected": "未连接", + "75s": "75 秒", + "15m": "15 分钟", + "1h": "1 小时", + "8h": "8 小时", + "untilStopped": "直到停止", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "最后一个客户端断开或连接心跳停止后,由 Oxbit 启动的运行时继续运行的时长。运行期间终端和任务会继续执行。", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "连接断开后重新连接到运行时,包括 iOS 应用返回前台时。" } diff --git a/packages/workbench/src/index.tsx b/packages/workbench/src/index.tsx index 017f2f2e..49a8c02b 100644 --- a/packages/workbench/src/index.tsx +++ b/packages/workbench/src/index.tsx @@ -60,6 +60,16 @@ import { displayShortcut, } from "./shortcuts.js"; export * from "./controller.js"; +export * from "./runtime-connector.js"; +import { RUNTIME_CONNECTOR_SERVICE, type RuntimeConnector, type RuntimeStatus } from "./runtime-connector.js"; +const noConnector = () => () => {}; +const idleConnection: RuntimeStatus = { state: "disconnected" }; +const connectionLabels: Record = { + connected: "Connected", connecting: "Connecting…", reconnecting: "Reconnecting…", failed: "Connection failed", disconnected: "Connect", +}; +const connectionIcons: Record = { + connected: "cloudCheck", connecting: "sync", reconnecting: "sync", failed: "cloudOff", disconnected: "cloud", +}; export { workspaceEntries } from "./files.js"; export { normalizeShortcut, @@ -389,6 +399,9 @@ export function Workbench({ const [conn, setConn] = useState( runtime?.connected ? "Connected" : "Browser workspace", ); + const connector = kernel.services.optional(RUNTIME_CONNECTOR_SERVICE); + const connection = useSyncExternalStore(connector?.subscribe ?? noConnector, connector?.status ?? (() => idleConnection)); + const connecting = connection.state === "connecting" || connection.state === "reconnecting"; useEffect(() => { const resize = () => setWidth(innerWidth); window.addEventListener("resize", resize); @@ -542,17 +555,31 @@ export function Workbench({
{onConnect && ( - + connector ? ( + + ) : ( + + ) )} {mode === "phone" && status @@ -762,7 +789,7 @@ export function Workbench({ )} {!s.focus && mode !== "phone" && (