Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,16 @@ heredoc, not MININIT.** rc.local:
read it. This is the primitive the `orcabot` CLI uses to run guest shell commands.

### Image staging
`vm/image.rs` stages `resources/vm/sandbox.img` → the data dir on launch, keyed by
a `.stamp` of the **source's nanosecond mtime + size** (not the dest's — the VM
mounts the image rw and bumps its mtime). Forcing a clean re-stage: delete
`<data>/vm/sandbox.img` + `.stamp`.
`vm/image.rs` stages `resources/vm/sandbox.img` → the **cache dir**
(`~/Library/Caches/com.orcabot.desktop/vm/`, resolved via `app_cache_dir()` in
`start_sandbox_vm`) on launch, keyed by a `.stamp` of the **source's nanosecond
mtime + size** (not the dest's — the VM mounts the image rw and bumps its mtime).
The image + staged runtime binaries live in the cache dir, **not** Application
Support, because they're large (~1GB) and fully regenerable; `migrate_vm_dir`
does a one-time move of an older install's `<app-data>/vm/` on first launch.
Forcing a clean re-stage: delete `<cache>/vm/sandbox*.img` + `.stamp` (the
packaged image is content-named `sandbox-<version>.img`). Note macOS may purge
the cache under disk pressure → a one-off re-download on next launch.

---

Expand Down
125 changes: 121 additions & 4 deletions desktop/app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

// REVISION: main-v12-surface-ws-hardening
const MODULE_REVISION: &str = "main-v12-surface-ws-hardening";
// REVISION: main-v13-vm-cache-dir
const MODULE_REVISION: &str = "main-v13-vm-cache-dir";

mod commands;
mod vm;
Expand Down Expand Up @@ -362,6 +362,82 @@ struct DesktopServices {
data_dir: Mutex<Option<PathBuf>>,
}

/// Relocate the staged VM dir from its old (app-data) location to the new (cache)
/// one on a best-effort basis. Ensures `new` holds a complete copy of the image if
/// one existed at `old` — but, apart from the atomic same-volume rename, it does
/// **not** remove `old`. The caller removes `old` via [`reclaim_old_vm_dir`] only
/// after `stage_vm_resources` confirms a valid image in `new`, so a failed
/// migration + download can never delete the last working image (directory
/// existence alone is not proof of a good image: staging creates the dir before the
/// download lands).
///
/// - No-op if `old` is missing, equals `new`, or `new` already exists (the caller
/// still reclaims `old` after a successful stage).
/// - Same-volume: `rename(old, new)` — instant + atomic, moves old→new in one step
/// (the macOS case; `old` is consumed atomically, so `new` is always complete).
/// - Cross-volume (`rename` fails with EXDEV; split XDG dirs / roaming profiles):
/// copy into a temp sibling, then atomically `rename` it into place so `new` is
/// never seen half-copied. `old` is left intact for the caller to reclaim after
/// staging succeeds. Any copy/swap failure drops the temp and preserves `old`.
fn migrate_vm_dir(old: &Path, new: &Path) {
if old == new || !old.exists() || new.exists() {
return;
}
if let Some(parent) = new.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Same-volume: atomic rename (consumes `old` in one atomic step).
if std::fs::rename(old, new).is_ok() {
eprintln!("[vm] migrated VM cache {} -> {}", old.display(), new.display());
return;
}
// Cross-volume: stage into a temp sibling (same volume as `new`), then swap it
// into place atomically so `new` is never seen half-copied. Leave `old` for the
// caller to reclaim after staging confirms a valid image.
let tmp = new.with_extension("migrating");
let _ = std::fs::remove_dir_all(&tmp); // clear any partial left by a prior crash
match copy_dir_all(old, &tmp) {
Ok(()) => match std::fs::rename(&tmp, new) {
Ok(()) => eprintln!("[vm] migrated VM cache (copy) {} -> {}", old.display(), new.display()),
Err(e) => {
let _ = std::fs::remove_dir_all(&tmp);
eprintln!("[vm] VM cache migration swap failed ({e}); old dir preserved");
}
},
Err(e) => {
let _ = std::fs::remove_dir_all(&tmp);
eprintln!("[vm] VM cache migration copy failed ({e}); old dir preserved, will re-download");
}
}
}

/// Remove a leftover pre-migration VM dir. Call this ONLY after
/// `stage_vm_resources` has confirmed a valid image in the cache dir, so a failed
/// download never strands the app without a working image. No-op on the same-volume
/// path (the rename already consumed `old`).
fn reclaim_old_vm_dir(old: &Path, new: &Path) {
if old != new && old.exists() {
let _ = std::fs::remove_dir_all(old);
}
}

/// Recursively copy a directory tree (regular files + subdirs; no symlink chasing
/// needed — the VM dir holds only plain files).
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}

impl DesktopServices {
fn new() -> Self {
Self {
Expand Down Expand Up @@ -708,6 +784,7 @@ impl DesktopServices {
fn start_sandbox_vm(
&self,
data_dir: &Path,
vm_dir: &Path,
resource_root: &Path,
) -> Result<(), vm::VMError> {
// The user accepted an app update → don't spin the VM up (or download its image)
Expand All @@ -717,6 +794,14 @@ impl DesktopServices {
return Ok(());
}

// One-time migration: the VM image + staged runtime binaries used to live under
// the app-data dir. They're large (~1GB) and fully regenerable, so they now live
// in the cache dir instead (Caches is the OS-purgeable bucket, and this keeps the
// image out of the precious Application Support tree). The old dir is reclaimed
// only after staging confirms a valid image below — never on mere dir existence.
let old_vm_dir = data_dir.join("vm");
migrate_vm_dir(&old_vm_dir, vm_dir);

// Check if VM resources exist
let vm_resource_paths = vm::image::VMResourcePaths::from_resource_root(resource_root);

Expand All @@ -738,7 +823,28 @@ impl DesktopServices {
}
}
};
let staged_paths = vm::image::stage_vm_resources(&vm_resource_paths, data_dir, &progress)?;
let staged_paths = match vm::image::stage_vm_resources(&vm_resource_paths, vm_dir, &progress) {
Ok(paths) => {
// Staging confirmed a valid image in the cache dir, so it's now safe to
// reclaim any leftover pre-migration VM dir. Gating on staging success —
// not directory existence — means a failed migration never deletes the
// last working image.
reclaim_old_vm_dir(&old_vm_dir, vm_dir);
paths
}
Err(cache_err) if old_vm_dir.as_path() != vm_dir && old_vm_dir.exists() => {
// The cache dir is full/unwritable, but the preserved pre-migration image
// is still intact — boot from it so the sandbox still works instead of
// failing every launch. Leave `old_vm_dir` in place; a later launch with a
// writable cache migrates it over and reclaims it.
eprintln!(
"[vm] cache staging failed ({cache_err}); falling back to preserved VM dir {}",
old_vm_dir.display()
);
vm::image::stage_vm_resources(&vm_resource_paths, &old_vm_dir, &progress)?
}
Err(e) => return Err(e),
};

// Create workspace directory
let workspace_dir = data_dir.join("workspace");
Expand Down Expand Up @@ -1166,10 +1272,21 @@ fn main() {
// Start sandbox VM in a background thread so the window appears immediately
// instead of blocking for up to 120s waiting for the VM health check.
let resource_root = resolve_resource_root(app);
// The large, regenerable VM artifacts (disk image + staged runtime binaries)
// live under the cache dir (~/Library/Caches/com.orcabot.desktop/vm), not
// Application Support — so a cleanup/uninstall reclaims the ~1GB and it sits in
// the OS-purgeable bucket. Fall back to the data dir if no cache dir resolves.
let vm_dir = match app.path().app_cache_dir() {
Ok(c) => c.join("vm"),
Err(_) => data_dir
.as_ref()
.map(|d| d.join("vm"))
.unwrap_or_else(|| PathBuf::from("vm")),
};
if let (Some(rr), Some(dd)) = (resource_root, data_dir) {
let vm_services = Arc::clone(&services);
std::thread::spawn(move || {
if let Err(err) = vm_services.start_sandbox_vm(&dd, &rr) {
if let Err(err) = vm_services.start_sandbox_vm(&dd, &vm_dir, &rr) {
eprintln!("Failed to start sandbox VM: {}", err);
eprintln!("Sandbox features will be unavailable.");
}
Expand Down
17 changes: 10 additions & 7 deletions desktop/app/src-tauri/src/vm/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! per image version and verified against a SHA-256 baked into the binary. The
//! small resources (kernel/initrd/vz-helper) are still staged from the bundle.
//
// REVISION: vm-image-ondemand-v1
// REVISION: vm-image-ondemand-v2-cache-dir

use super::VMError;
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -267,16 +267,19 @@ impl VMResourcePaths {
/// Stage all VM resources to the app data directory.
pub fn stage_vm_resources(
resource_paths: &VMResourcePaths,
data_dir: &Path,
vm_dir: &Path,
progress: &dyn Fn(u64, u64),
) -> Result<VMResourcePaths, VMError> {
let vm_dir = data_dir.join("vm");
// vm_dir lives under the CACHE dir (resolved in start_sandbox_vm): the disk
// image + staged runtime binaries are large and fully regenerable, so they
// stay out of the precious Application Support tree.
let vm_dir = vm_dir.to_path_buf();
fs::create_dir_all(&vm_dir)?;

// The disk image is NOT bundled in the app (it would bloat every
// auto-update), so fetch/adopt it on demand instead of staging from a
// bundled resource.
let staged_image = ensure_vm_image(&resource_paths.image, data_dir, progress)?;
let staged_image = ensure_vm_image(&resource_paths.image, &vm_dir, progress)?;

let staged_kernel = if let Some(ref kernel) = resource_paths.kernel {
Some(stage_image(kernel, &vm_dir)?)
Expand Down Expand Up @@ -359,7 +362,7 @@ fn read_marker(marker: &Path) -> Option<String> {
/// may be adopted without a download only while it's still the required version.
const LEGACY_IMAGE_VERSION: &str = "v1";

/// Ensure the VM disk image is present in the data dir and return its path.
/// Ensure the VM disk image is present in the VM cache dir and return its path.
///
/// The staged image is named by CONTENT (`sandbox-<token>.img`), never a fixed
/// `sandbox.img`: macOS/Virtualization.framework caches a disk image's SIZE keyed
Expand All @@ -377,11 +380,11 @@ const LEGACY_IMAGE_VERSION: &str = "v1";
/// 4. otherwise download the gz artifact, verify its SHA-256, decompress.
pub fn ensure_vm_image(
resource_image: &Path,
data_dir: &Path,
vm_dir: &Path,
progress: &dyn Fn(u64, u64),
) -> Result<PathBuf, VMError> {
let manifest = vm_image_manifest();
let vm_dir = data_dir.join("vm");
let vm_dir = vm_dir.to_path_buf();
fs::create_dir_all(&vm_dir)?;

// 0. Dev override: ORCABOT_VM_IMAGE forces a specific local image (raw .img or
Expand Down
Loading