diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index cd55876..43618af 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -177,7 +177,7 @@ impl AuthBridgeManager { pub async fn start( &self, project_id: String, - container_id: String, + container: ContainerTarget, app: AppHandle, store: Arc, ) { @@ -199,12 +199,12 @@ impl AuthBridgeManager { log::info!( "Auth bridge: starting for project {} (container {})", project_id, - &container_id[..container_id.len().min(12)] + &container.id()[..container.id().len().min(12)] ); let poller = tokio::spawn(poll_loop( project_id.clone(), - ContainerTarget::docker(container_id), + container, epoch, app, store, diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs index d40588d..4c8ad96 100644 --- a/app/src-tauri/src/auth_bridge/tunnel.rs +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -322,7 +322,7 @@ async fn tunnel_connection( } // The bytes already off the socket go back on the wire first, byte-exact. - tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await + tunnel_connection_with_prelude(container, target, stream, port, head.into_buffer()).await } /// What the first bytes of an accepted connection turned out to be. @@ -514,29 +514,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict { /// head off the socket to check a token, and then has to put those same bytes /// back on the wire. Passing them here keeps the byte stream exact, rather than /// re-serialising a parsed request. -/// -/// Takes a Docker container id because that is what the browser-view proxy -/// holds; [`tunnel_into_with_prelude`] is the same thing for a -/// [`ContainerTarget`] already in hand. pub async fn tunnel_connection_with_prelude( - container_id: String, - target: String, - stream: TcpStream, - port: u16, - prelude: Vec, -) { - tunnel_into_with_prelude( - ContainerTarget::docker(container_id), - target, - stream, - port, - prelude, - ) - .await -} - -/// [`tunnel_connection_with_prelude`] for a [`ContainerTarget`]. -pub async fn tunnel_into_with_prelude( container: ContainerTarget, target: String, stream: TcpStream, diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index 1b4a5af..258b02e 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -6,6 +6,7 @@ use tauri::{AppHandle, State}; use crate::browser_view::install::{self, BrowserSetupOutcome}; use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus}; +use crate::host::ContainerTarget; use crate::AppState; /// Turn the pane on or off for a project. @@ -57,12 +58,12 @@ pub async fn set_browser_view_enabled( return Ok(manager().status(&project_id, false).await); } - let container_id = running_container(&state, &project_id, "opening the browser view").await?; + let container = running_container(&state, &project_id, "opening the browser view").await?; manager() .start( project_id, - container_id, + container, app_handle, state.projects_store.clone(), ) @@ -106,8 +107,8 @@ pub async fn check_browser_view_support( project_id: String, state: State<'_, AppState>, ) -> Result { - let container_id = running_container(&state, &project_id, "checking for Playwright").await?; - crate::browser_view::detect::detect(&container_id).await + let container = running_container(&state, &project_id, "checking for Playwright").await?; + crate::browser_view::detect::detect(&container).await } /// Install `playwright` and `@playwright/cli` into the container. @@ -125,8 +126,8 @@ pub async fn install_browser_view_support( app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { - let container_id = running_container(&state, &project_id, "installing Playwright").await?; - install::install_packages(&app_handle, &project_id, &container_id).await + let container = running_container(&state, &project_id, "installing Playwright").await?; + install::install_packages(&app_handle, &project_id, &container).await } /// Install a browser — `chromium` (Playwright's own build, for scripts that @@ -143,8 +144,8 @@ pub async fn install_browser_view_browser( state: State<'_, AppState>, ) -> Result { let target = install::BrowserTarget::parse(&browser)?; - let container_id = running_container(&state, &project_id, "installing a browser").await?; - install::install_browser(&app_handle, &project_id, &container_id, target).await + let container = running_container(&state, &project_id, "installing a browser").await?; + install::install_browser(&app_handle, &project_id, &container, target).await } /// Detach the view into a window of its own, or raise the one already open. @@ -240,17 +241,17 @@ pub async fn open_page_in_container_browser( if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { return Err("Only http:// and https:// URLs can be opened in the browser.".to_string()); } - let container_id = running_container(&state, &project_id, "opening a page").await?; + let container = running_container(&state, &project_id, "opening a page").await?; crate::commands::project_commands::emit_progress( &app_handle, &project_id, "Checking the container for Playwright…", ); - let detection = crate::browser_view::detect::detect(&container_id).await?; + let detection = crate::browser_view::detect::detect(&container).await?; let opened = page::open( &app_handle, &project_id, - &container_id, + &container, &detection, trimmed, page::Viewport::sane(width, height), @@ -273,7 +274,7 @@ pub async fn open_page_in_container_browser( manager() .start( project_id.clone(), - container_id, + container, app_handle.clone(), state.projects_store.clone(), ) @@ -309,8 +310,8 @@ pub async fn set_container_page_viewport( height: u32, state: State<'_, AppState>, ) -> Result<(), String> { - let container_id = running_container(&state, &project_id, "resizing the page").await?; - page::set_viewport(&container_id, page::Viewport::sane(width, height)).await + let container = running_container(&state, &project_id, "resizing the page").await?; + page::set_viewport(&container, page::Viewport::sane(width, height)).await } /// State of the page this opened, if any. Never fails: "no page" is an answer. @@ -319,10 +320,10 @@ pub async fn get_container_page_state( project_id: String, state: State<'_, AppState>, ) -> Result { - let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else { + let Ok(container) = running_container(&state, &project_id, "reading the page").await else { return Ok(page::PageState::default()); }; - Ok(page::state(&container_id).await) + Ok(page::state(&container).await) } /// Close the page this opened, leaving the view itself running. @@ -331,8 +332,8 @@ pub async fn close_container_page( project_id: String, state: State<'_, AppState>, ) -> Result<(), String> { - let container_id = running_container(&state, &project_id, "closing the page").await?; - page::close(&container_id).await; + let container = running_container(&state, &project_id, "closing the page").await?; + page::close(&container).await; Ok(()) } @@ -356,8 +357,8 @@ pub async fn set_browser_view_match_window( let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else { return Ok(()); }; - let container_id = running_container(&state, &project_id, "matching the window").await?; - page::set_viewport(&container_id, page::Viewport::sane(width, height)).await + let container = running_container(&state, &project_id, "matching the window").await?; + page::set_viewport(&container, page::Viewport::sane(width, height)).await } /// Whether match-window mode is on. Read on mount, like the rest of the @@ -391,28 +392,25 @@ async fn running_container( state: &State<'_, AppState>, project_id: &str, action: &str, -) -> Result { +) -> Result { let project = state .projects_store .get(project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - let Some(container_id) = project.container_id.clone() else { + let Some(container) = ContainerTarget::for_project(&project) else { return Err(format!( "This project has no container yet. Start it before {}.", action )); }; - if !crate::docker::container::is_container_running(&container_id) - .await - .unwrap_or(false) - { + if !container.is_running().await.unwrap_or(false) { return Err(format!( "The container for “{}” isn't running. Start it before {}.", project.name, action )); } - Ok(container_id) + Ok(container) } #[cfg(test)] diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs index eb88a06..adc5876 100644 --- a/app/src-tauri/src/browser_view/detect.rs +++ b/app/src-tauri/src/browser_view/detect.rs @@ -277,12 +277,7 @@ impl PlaywrightDetection { /// Playwright installed with `npm i -g`, or merely *run* once through /// `npx @playwright/mcp`, is found as readily as one in /// `/workspace/node_modules`. -pub async fn detect(container_id: &str) -> Result { - detect_in(&ContainerTarget::docker(container_id)).await -} - -/// [`detect`] against a target already in hand. -pub async fn detect_in(container: &ContainerTarget) -> Result { +pub async fn detect(container: &ContainerTarget) -> Result { let output = container .exec_oneshot(vec![ "node".to_string(), diff --git a/app/src-tauri/src/browser_view/install.rs b/app/src-tauri/src/browser_view/install.rs index a6c149d..40ea69d 100644 --- a/app/src-tauri/src/browser_view/install.rs +++ b/app/src-tauri/src/browser_view/install.rs @@ -238,16 +238,15 @@ pub struct BrowserSetupOutcome { pub async fn install_packages( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, ) -> Result { - let container = ContainerTarget::docker(container_id); emit_progress( app, project_id, &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), ); - let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?; + let mut step = npm_install(app, project_id, container, &[VIEWER_PACKAGE]).await?; if step.exit_code != 0 { return Err(format!( "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", @@ -264,9 +263,9 @@ pub async fn install_packages( // see the note on `npm_install`. The pin can only be read after the first // install has written the manifest, which is why this stays two commands // rather than one. - let spec = pinned_playwright_spec(&container).await; + let spec = pinned_playwright_spec(container).await; emit_progress(app, project_id, &format!("Installing {}…", spec)); - let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?; + let second = npm_install(app, project_id, container, &[VIEWER_PACKAGE, &spec]).await?; if second.exit_code != 0 { return Err(format!( "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", @@ -278,7 +277,7 @@ pub async fn install_packages( step.log = merge_logs(step.log, second.log); emit_progress(app, project_id, "Re-checking what the container has…"); - let detection = detect::detect_in(&container).await?; + let detection = detect::detect(container).await?; // A probe that still finds something missing after a successful npm run is // the interesting case, so it is surfaced rather than swallowed. And a @@ -410,11 +409,10 @@ fn merge_logs(first: String, second: String) -> String { pub async fn install_browser( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, target: BrowserTarget, ) -> Result { - let container = ContainerTarget::docker(container_id); - let before = detect::detect_in(&container).await?; + let before = detect::detect(container).await?; let Some(cli) = before.playwright_cli.clone() else { return Err( "Playwright isn't installed in this container yet, so there is nothing to install a \ @@ -458,7 +456,7 @@ pub async fn install_browser( project_id, "Step 1/3 — checking whether this container already has the browser system libraries…", ); - let state = check_libraries(&container, &cli, target).await; + let state = check_libraries(container, &cli, target).await; match &state { LibraryState::Present => { emit_progress( @@ -482,7 +480,7 @@ pub async fn install_browser( let deps = run_step( app, project_id, - &container, + container, "root", "/tmp", vec![ @@ -530,7 +528,7 @@ pub async fn install_browser( let dl = run_step( app, project_id, - &container, + container, user, workdir, vec![ @@ -559,7 +557,7 @@ pub async fn install_browser( project_id, &format!("Step 3/3 — checking that {} actually launches…", target.cli_name()), ); - let verdict = verify_launch(&container, &before, target).await; + let verdict = verify_launch(container, &before, target).await; push_section(&mut log, &verdict.detail); if !verdict.ok { warning = merge( @@ -590,7 +588,7 @@ pub async fn install_browser( } emit_progress(app, project_id, "Re-checking what the container has…"); - let detection = detect::detect_in(&container).await?; + let detection = detect::detect(container).await?; Ok(BrowserSetupOutcome { detection, diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index 374acb3..624bf2c 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -281,7 +281,7 @@ impl BrowserViewManager { pub async fn start( &self, project_id: String, - container_id: String, + container: ContainerTarget, app: AppHandle, store: Arc, ) -> Result { @@ -299,8 +299,7 @@ impl BrowserViewManager { return Ok(self.status(&project_id, true).await); } - let container = ContainerTarget::docker(container_id.as_str()); - let detection = detect::detect_in(&container).await?; + let detection = detect::detect(&container).await?; if !detection.is_usable() { let blocker = detection.blocker().unwrap_or_else(|| { "Playwright is present but incomplete in this container.".to_string() @@ -328,7 +327,7 @@ impl BrowserViewManager { // `--host 127.0.0.1` is ours to set, so the family is known and there is // no need to go back to /proc/net to work it out. let proxy = match BrowserViewProxy::bind( - container_id.clone(), + container.clone(), container_port, PortFamily::V4, token.clone(), diff --git a/app/src-tauri/src/browser_view/page.rs b/app/src-tauri/src/browser_view/page.rs index c2d134a..f30c389 100644 --- a/app/src-tauri/src/browser_view/page.rs +++ b/app/src-tauri/src/browser_view/page.rs @@ -91,7 +91,7 @@ pub struct PageState { pub async fn open( app: &AppHandle, project_id: &str, - container_id: &str, + container: &ContainerTarget, detection: &PlaywrightDetection, url: &str, viewport: Viewport, @@ -115,17 +115,17 @@ pub async fn open( // Reuse a helper that is already up. Relaunching would throw away the // browser's cookies and storage — which for the auth case means signing in // again to reach the second page, having just signed in on the first. - if state(container_id).await.ready { + if state(container).await.ready { emit_progress(app, project_id, "Navigating the container's browser…"); - set_viewport(container_id, viewport).await?; - navigate(container_id, url).await?; - if let Some(state) = wait_for_url(container_id, url).await { + set_viewport(container, viewport).await?; + navigate(container, url).await?; + if let Some(state) = wait_for_url(container, url).await { return Ok(state); } // It stopped answering; fall through and start a fresh one. } - close(container_id).await; + close(container).await; // Cold start: a browser launch plus a page load, which is the several // seconds the user would otherwise spend wondering whether the click // registered. @@ -150,7 +150,7 @@ pub async fn open( shell_quote(&script), HELPER_LOG ); - ContainerTarget::docker(container_id) + container .exec_oneshot_as( "claude", vec!["sh".to_string(), "-c".to_string(), launcher], @@ -160,36 +160,36 @@ pub async fn open( .map_err(|e| format!("Could not start the browser helper: {}", e))?; emit_progress(app, project_id, "Waiting for the page to load…"); - wait_until_ready(container_id).await + wait_until_ready(container).await } /// Resize the open page. Cheap enough to call from a window-resize handler. -pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> { +pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> { write_control( - container_id, + container, serde_json::json!({ "viewport": viewport }).to_string(), ) .await } /// Navigate the open page without relaunching the browser. -pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> { - write_control(container_id, serde_json::json!({ "url": url }).to_string()).await +pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> { + write_control(container, serde_json::json!({ "url": url }).to_string()).await } /// Ask the helper to shut down. Best effort: a container that has none is the /// normal case, and the caller is usually about to start one anyway. -pub async fn close(container_id: &str) { - let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await; +pub async fn close(container: &ContainerTarget) { + let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await; } /// Current state, or a default when no helper has ever run here. -pub async fn state(container_id: &str) -> PageState { +pub async fn state(container: &ContainerTarget) -> PageState { let script = format!( "try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}", STATE_PATH ); - let Ok((out, _)) = ContainerTarget::docker(container_id) + let Ok((out, _)) = container .exec_oneshot_as( "claude", vec!["node".to_string(), "-e".to_string(), script], @@ -204,12 +204,12 @@ pub async fn state(container_id: &str) -> PageState { /// Write the control file through Node rather than a shell redirect, so a URL /// is never interpreted by `sh`. -async fn write_control(container_id: &str, json: String) -> Result<(), String> { +async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> { let script = format!( "require('fs').writeFileSync('{}',process.argv[1]);", CONTROL_PATH ); - ContainerTarget::docker(container_id) + container .exec_oneshot_as( "claude", vec!["node".to_string(), "-e".to_string(), script, json], @@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> { /// Bounded much tighter than a cold start: the browser is already up, so this /// is one navigation. `None` means it stopped answering, and the caller starts /// a fresh helper rather than reporting a page that isn't there. -async fn wait_for_url(container_id: &str, url: &str) -> Option { +async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option { let deadline = std::time::Instant::now() + REUSE_TIMEOUT; loop { - let state = state(container_id).await; + let state = state(container).await; if state.ready && state.url.as_deref() == Some(url) { return Some(state); } @@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option { } /// Poll the state file until the helper says the page is up, or says why not. -async fn wait_until_ready(container_id: &str) -> Result { +async fn wait_until_ready(container: &ContainerTarget) -> Result { let deadline = std::time::Instant::now() + READY_TIMEOUT; loop { - let state = state(container_id).await; + let state = state(container).await; if let Some(error) = state.error.clone() { return Err(error); } diff --git a/app/src-tauri/src/browser_view/popout.rs b/app/src-tauri/src/browser_view/popout.rs index 1b84cf3..0b5bb8f 100644 --- a/app/src-tauri/src/browser_view/popout.rs +++ b/app/src-tauri/src/browser_view/popout.rs @@ -255,15 +255,15 @@ fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) { } let state = app.state::(); - let Some(container_id) = state + let Some(container) = state .projects_store .get(&project_id) - .and_then(|p| p.container_id) + .and_then(|p| crate::host::ContainerTarget::for_project(&p)) else { return; }; if let Err(e) = super::page::set_viewport( - &container_id, + &container, super::page::Viewport::sane(width, height), ) .await diff --git a/app/src-tauri/src/browser_view/proxy.rs b/app/src-tauri/src/browser_view/proxy.rs index 522f6dd..8d4c42c 100644 --- a/app/src-tauri/src/browser_view/proxy.rs +++ b/app/src-tauri/src/browser_view/proxy.rs @@ -62,6 +62,7 @@ use tokio::task::{JoinHandle, JoinSet}; use crate::auth_bridge::proc_net::PortFamily; use crate::auth_bridge::tunnel::tunnel_connection_with_prelude; +use crate::host::ContainerTarget; /// Host loopback ports the pane may be served on, and therefore the exact set of /// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a @@ -103,9 +104,9 @@ impl Drop for BrowserViewProxy { impl BrowserViewProxy { /// Take the first free port in [`PROXY_PORTS`] on the host loopback and - /// start gating connections into `container_id`'s `container_port`. + /// start gating connections into `container`'s `container_port`. pub async fn bind( - container_id: String, + container: ContainerTarget, container_port: u16, family: PortFamily, token: String, @@ -121,7 +122,7 @@ impl BrowserViewProxy { Ok(listener) => { let task = tokio::spawn(accept_loop( listener, - container_id, + container, family.socat_target(container_port), container_port, token, @@ -172,7 +173,7 @@ fn host_authorities(port: u16) -> Vec { #[allow(clippy::too_many_arguments)] async fn accept_loop( listener: TcpListener, - container_id: String, + container: ContainerTarget, target: String, container_port: u16, token: String, @@ -195,7 +196,7 @@ async fn accept_loop( let _ = stream.set_nodelay(true); conns.spawn(serve_connection( stream, - container_id.clone(), + container.clone(), target.clone(), container_port, token.clone(), @@ -214,7 +215,7 @@ async fn accept_loop( #[allow(clippy::too_many_arguments)] async fn serve_connection( mut stream: TcpStream, - container_id: String, + container: ContainerTarget, target: String, container_port: u16, token: String, @@ -249,7 +250,7 @@ async fn serve_connection( // Authorized: hand the socket to the same socat-over-Docker-exec tunnel the // auth bridge uses, replaying the head we had to buffer to make the call. - tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await; + tunnel_connection_with_prelude(container, target, stream, container_port, head).await; } /// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail. diff --git a/app/src-tauri/src/commands/auth_bridge_commands.rs b/app/src-tauri/src/commands/auth_bridge_commands.rs index dddff71..70bb354 100644 --- a/app/src-tauri/src/commands/auth_bridge_commands.rs +++ b/app/src-tauri/src/commands/auth_bridge_commands.rs @@ -5,6 +5,7 @@ use tauri::{AppHandle, State}; use crate::auth_bridge::AuthBridgeStatus; +use crate::host::ContainerTarget; use crate::AppState; /// Turn the bridge on or off for a project and return the resulting status. @@ -29,16 +30,13 @@ pub async fn set_auth_bridge_enabled( .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - if let Some(container_id) = project.container_id { - if crate::docker::container::is_container_running(&container_id) - .await - .unwrap_or(false) - { + if let Some(container) = ContainerTarget::for_project(&project) { + if container.is_running().await.unwrap_or(false) { state .auth_bridge .start( project_id.clone(), - container_id, + container, app_handle, state.projects_store.clone(), ) diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 1a472df..27458f7 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -1202,15 +1202,12 @@ pub(crate) struct FetchedFile { /// file — or the whole *directory tree*, since the type check happens after the /// read — landed in host RAM twice. This function buffers, so every caller of /// it must name a ceiling. -/// -/// Takes a container id rather than a [`ContainerTarget`] because the file -/// viewer's commands call it with the id they already hold. pub(crate) async fn fetch_container_file( - container_id: &str, + container: &ContainerTarget, container_path: &str, max_bytes: u64, ) -> Result { - let mut stream = ContainerTarget::docker(container_id).download_archive(container_path); + let mut stream = container.download_archive(container_path); // A tar member is a 512-byte header plus payload padded to 512. 8 KiB of // slack past the payload cap guarantees the header and the whole capped @@ -1297,7 +1294,7 @@ pub async fn read_container_file( .ok_or_else(|| "Container not running".to_string())?; let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES); - let fetched = fetch_container_file(target.id(), &path, cap).await?; + let fetched = fetch_container_file(&target, &path, cap).await?; Ok(FileContents { contents_base64: BASE64.encode(&fetched.bytes), @@ -1451,14 +1448,14 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String { /// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath` /// is the first thing to touch the container, so a stopped project fails inside /// path *validation* and reads like the path was the problem. -/// -/// Takes a container id rather than a [`ContainerTarget`] because the file -/// viewer's commands call it with the id they already hold. -pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> { +pub(crate) async fn require_running( + container: &ContainerTarget, + action: &str, +) -> Result<(), String> { // An unreachable daemon is an error; an inspect failure (container gone) // reads as "not running" — `is_running`'s contract, and what this did // when it inspected the container itself. - let running = ContainerTarget::docker(container_id).is_running().await?; + let running = container.is_running().await?; if running { return Ok(()); } @@ -1532,7 +1529,7 @@ pub async fn download_container_file( let target = ContainerTarget::for_project(&project) .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; - require_running(target.id(), "saving a file to the host").await?; + require_running(&target, "saving a file to the host").await?; let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else { // Dismissed. Not an error, and deliberately distinguishable from one: @@ -1887,7 +1884,7 @@ pub async fn upload_files_to_container( // Both checks before the dialog, so a project that cannot receive files // says so instead of asking the user to choose some first. - require_running(target.id(), "uploading files").await?; + require_running(&target, "uploading files").await?; resolve_container_dir(&target, "Upload", &container_dir).await?; let Some(picked) = pick_files_to_upload(&window).await else { @@ -2241,7 +2238,7 @@ pub async fn download_container_backup( let target = ContainerTarget::for_project(&project) .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; - require_running(target.id(), "backing up").await?; + require_running(&target, "backing up").await?; let path = container_path.unwrap_or_else(|| "/workspace".to_string()); // Read-only source: `tar -C` it, so absoluteness and `..` are what matter. diff --git a/app/src-tauri/src/commands/file_viewer_commands.rs b/app/src-tauri/src/commands/file_viewer_commands.rs index dc9f65f..8e8fb11 100644 --- a/app/src-tauri/src/commands/file_viewer_commands.rs +++ b/app/src-tauri/src/commands/file_viewer_commands.rs @@ -18,6 +18,7 @@ use crate::file_viewer::registry::{ use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates}; use crate::file_viewer::window::open_viewer_window; use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES}; +use crate::host::ContainerTarget; use crate::models::Project; use crate::AppState; @@ -113,13 +114,11 @@ fn project_of(state: &AppState, project_id: &str) -> Result { } /// `action` completes "Start the project before …", e.g. "saving this file". -async fn running_container_of(project: &Project, action: &str) -> Result { - let container_id = project - .container_id - .clone() +async fn running_container_of(project: &Project, action: &str) -> Result { + let container = ContainerTarget::for_project(project) .ok_or_else(|| not_running_message(action, "files live in its container"))?; - require_running(&container_id, action).await?; - Ok(container_id) + require_running(&container, action).await?; + Ok(container) } /// The container of the project a viewer window belongs to, if it is running. @@ -127,7 +126,7 @@ async fn running_container_for( state: &AppState, target: &ViewerTarget, action: &str, -) -> Result { +) -> Result { running_container_of(&project_of(state, &target.project_id)?, action).await } @@ -156,17 +155,17 @@ pub async fn open_file_viewer( ) -> Result<(), String> { require_main(window.label())?; let project = project_of(&state, &project_id)?; - let container_id = running_container_of(&project, "opening files").await?; + let container = running_container_of(&project, "opening files").await?; let mounts: Vec = project.paths.iter().map(|p| p.mount_name.clone()).collect(); let mut candidates = candidate_paths(&path, &mounts)?; - let mut matches = probe_candidates(&container_id, &candidates).await?; + let mut matches = probe_candidates(&container, &candidates).await?; // Not in the project: try home and /tmp, as a second exec so a project file // never becomes a chooser because a namesake sits in /tmp. if matches.is_empty() { let fallbacks = fallback_candidate_paths(&path); if !fallbacks.is_empty() { - matches = probe_candidates(&container_id, &fallbacks).await?; + matches = probe_candidates(&container, &fallbacks).await?; candidates.extend(fallbacks); } } @@ -253,9 +252,9 @@ pub async fn viewer_read_file( ) -> Result { let (_label, target) = own_target(&window, ®istry)?; let path = resolved_path(&target)?; - let container_id = running_container_for(&state, &target, "opening files").await?; + let container = running_container_for(&state, &target, "opening files").await?; let cap = max_bytes.clamp(1, MAX_READ_BYTES); - let fetched = fetch_container_file(&container_id, &path, cap).await?; + let fetched = fetch_container_file(&container, &path, cap).await?; let (editable, readonly_reason) = match validate_container_write_path("File", &path) { Ok(()) => (true, None), Err(reason) => (false, Some(reason)), @@ -278,8 +277,8 @@ pub async fn viewer_poll_file( ) -> Result { let (_label, target) = own_target(&window, ®istry)?; let path = resolved_path(&target)?; - let container_id = running_container_for(&state, &target, "checking this file for changes").await?; - poll_file(&container_id, &path).await + let container = running_container_for(&state, &target, "checking this file for changes").await?; + poll_file(&container, &path).await } /// Errors from `write_file` pass through unchanged: the frontend matches the @@ -302,8 +301,8 @@ pub async fn viewer_write_file( let bytes = BASE64 .decode(contents_base64.as_bytes()) .map_err(|_| "The editor sent malformed content.".to_string())?; - let container_id = running_container_for(&state, &target, "saving this file").await?; - write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await + let container = running_container_for(&state, &target, "saving this file").await?; + write_file(&container, &state.exec_manager, &path, &bytes, &base_hash).await } #[cfg(test)] diff --git a/app/src-tauri/src/commands/marketplace_commands.rs b/app/src-tauri/src/commands/marketplace_commands.rs index c346224..c59a127 100644 --- a/app/src-tauri/src/commands/marketplace_commands.rs +++ b/app/src-tauri/src/commands/marketplace_commands.rs @@ -945,10 +945,10 @@ pub async fn apply_marketplace_now( let mut results = Vec::new(); for p in projects { let running = match ContainerTarget::for_project(&p) { - Some(target) => target.is_running().await.unwrap_or(false), - None => false, + Some(target) if target.is_running().await.unwrap_or(false) => Some(target), + _ => None, }; - if !running { + let Some(target) = running else { if project_id.is_some() { return Err(format!( "\"{}\" is not running. Its marketplace items are applied when it starts.", @@ -956,9 +956,8 @@ pub async fn apply_marketplace_now( )); } continue; - } - let cid = p.container_id.clone().unwrap_or_default(); - let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await; + }; + let report = mk::sync_project(&state.marketplace, &settings, &p, &target).await; let _ = app_handle.emit( mk::SYNC_FINISHED_EVENT, serde_json::json!({ "project_id": p.id, "report": report }), diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index a52a17b..becd18c 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1491,7 +1491,8 @@ async fn start_project_container_locked( .auth_bridge .start( project_id.clone(), - container_id.clone(), + // phase 1b: lifecycle will hand out the target + crate::host::ContainerTarget::docker(container_id.clone()), app_handle.clone(), state.projects_store.clone(), ) @@ -1695,12 +1696,12 @@ pub async fn reconcile_project_statuses( // bridge lives in this process, so re-arm it here. `start` is // idempotent, so a bridge that is already polling is untouched. if project.auth_bridge_enabled { - if let Some(ref container_id) = project.container_id { + if let Some(target) = crate::host::ContainerTarget::for_project(&project) { state .auth_bridge .start( project.id.clone(), - container_id.clone(), + target, app_handle.clone(), state.projects_store.clone(), ) diff --git a/app/src-tauri/src/file_viewer/poll.rs b/app/src-tauri/src/file_viewer/poll.rs index 28dd359..905e172 100644 --- a/app/src-tauri/src/file_viewer/poll.rs +++ b/app/src-tauri/src/file_viewer/poll.rs @@ -40,7 +40,10 @@ pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll { ViewerPoll { exists: true, hash, size } } -pub async fn poll_file(container_id: &str, container_path: &str) -> Result { +pub async fn poll_file( + container: &ContainerTarget, + container_path: &str, +) -> Result { let cmd = vec![ "sh".to_string(), "-c".to_string(), @@ -48,7 +51,7 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result Vec { } pub async fn probe_candidates( - container_id: &str, + container: &ContainerTarget, candidates: &[String], ) -> Result, String> { let mut cmd: Vec = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()]; cmd.extend(candidates.iter().cloned()); - let (stdout, _stderr, _code) = ContainerTarget::docker(container_id) + let (stdout, _stderr, _code) = container .exec_oneshot_streams_as("claude", cmd, Vec::new()) .await?; Ok(parse_probe_output(&stdout)) diff --git a/app/src-tauri/src/file_viewer/write.rs b/app/src-tauri/src/file_viewer/write.rs index d11315c..28172f2 100644 --- a/app/src-tauri/src/file_viewer/write.rs +++ b/app/src-tauri/src/file_viewer/write.rs @@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result } pub async fn write_file( - container_id: &str, + container: &ContainerTarget, exec_manager: &ExecSessionManager, target: &str, bytes: &[u8], @@ -180,9 +180,8 @@ pub async fn write_file( ) -> Result { check_write_input(bytes.len(), base_hash)?; let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple()); - let container = ContainerTarget::docker(container_id); let tmp_path = exec_manager - .write_file_to_container(&container, &tmp_name, bytes) + .write_file_to_container(container, &tmp_name, bytes) .await?; let cmd = write_command(target, &tmp_path, base_hash); let (stdout, stderr, code) = container diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index 010b130..bec3b98 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -26,6 +26,7 @@ use crate::models::marketplace::{ effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport, }; +use crate::host::ContainerTarget; use crate::models::{AppSettings, Project}; use catalog::{item_fingerprint, parse_catalog}; use tree::GitTree; @@ -505,7 +506,7 @@ pub async fn sync_project( mgr: &MarketplaceManager, settings: &AppSettings, project: &Project, - container_id: &str, + container: &ContainerTarget, ) -> SyncReport { let lock = mgr.sync_lock(&project.id); let _sync_guard = lock.lock().await; @@ -533,7 +534,7 @@ pub async fn sync_project( items, p.skipped.len() ); - let result = sync::sync_container(container_id, &p).await; + let result = sync::sync_container(container, &p).await; sync::with_payload_skips(sync::report_from_result(result), &p.skipped) } Err(e) => sync::report_from_result(Err(e)), @@ -561,8 +562,13 @@ pub fn spawn_project_sync( if !should_sync(&mgr, &settings, &project) { return; } + // phase 1b: lifecycle will hand out the target. Until then the start path + // only has the fresh container's id: `project.container_id` is written + // after this is called, so `ContainerTarget::for_project` could name a + // container that was just replaced, or none at all. + let container = ContainerTarget::docker(container_id); tauri::async_runtime::spawn(async move { - let report = sync_project(&mgr, &settings, &project, &container_id).await; + let report = sync_project(&mgr, &settings, &project, &container).await; if !report.errors.is_empty() { log::warn!( "Marketplace sync for project {} reported errors: {:?}", @@ -1132,9 +1138,10 @@ mod tests { let lock = mgr.sync_lock(&project.id); let guard = lock.lock().await; + let nowhere = ContainerTarget::docker("no-such-container"); let blocked = tokio::time::timeout( std::time::Duration::from_millis(300), - sync_project(&mgr, &settings, &project, "no-such-container"), + sync_project(&mgr, &settings, &project, &nowhere), ) .await; assert!(blocked.is_err(), "sync must wait while another sync holds the lock"); @@ -1143,7 +1150,7 @@ mod tests { // No Docker (or no such container) here: the failure becomes a stored // report instead of an error. - let report = sync_project(&mgr, &settings, &project, "no-such-container").await; + let report = sync_project(&mgr, &settings, &project, &nowhere).await; assert_eq!(report.errors.len(), 1, "{report:?}"); assert!(!report.finished_at.is_empty()); assert_eq!(mgr.report(&project.id), Some(report)); diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs index 7c7352b..0c84a08 100644 --- a/app/src-tauri/src/marketplace/sync.rs +++ b/app/src-tauri/src/marketplace/sync.rs @@ -92,9 +92,11 @@ fn tail(text: &str, max: usize) -> &str { /// Wait for readiness, upload the payload and the script, run the script as /// `claude`, and return its report. -pub async fn sync_container(container_id: &str, payload: &Payload) -> Result { - let target = ContainerTarget::docker(container_id); - wait_until_ready(&target).await?; +pub async fn sync_container( + target: &ContainerTarget, + payload: &Payload, +) -> Result { + wait_until_ready(target).await?; let (out, code) = target .exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])