Host phase 1a follow-up: pass ContainerTarget instead of container ids
Functions that took a bare container id and built `ContainerTarget::docker(id)` internally now take a target, and their callers resolve one from the project (`ContainerTarget::for_project`) or carry the one they already hold. Long-lived holders (auth bridge poller, browser-view proxy/session, file viewer commands) store the target. Collapses the string/target wrapper pairs: `detect::detect` now takes a target (`detect_in` removed) and `tunnel_connection_with_prelude` takes a target (`tunnel_into_with_prelude` removed). No behaviour change. Two temporary Docker constructions remain on the container-start path, marked "phase 1b": the auth bridge start in `start_project_container_locked` and `marketplace::spawn_project_sync`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ec100e06c
commit
83d63e756d
19 files changed
+143
-169
No files matched your search
@@ -177,7 +177,7 @@ impl AuthBridgeManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) {
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u8>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||
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<BrowserSetupOutcome, String> {
|
||||
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<BrowserSetupOutcome, String> {
|
||||
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<page::PageState, String> {
|
||||
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<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
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)]
|
||||
|
||||
@@ -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<PlaywrightDetection, String> {
|
||||
detect_in(&ContainerTarget::docker(container_id)).await
|
||||
}
|
||||
|
||||
/// [`detect`] against a target already in hand.
|
||||
pub async fn detect_in(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
pub async fn detect(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
let output = container
|
||||
.exec_oneshot(vec![
|
||||
"node".to_string(),
|
||||
|
||||
@@ -238,16 +238,15 @@ pub struct BrowserSetupOutcome {
|
||||
pub async fn install_packages(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
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<BrowserSetupOutcome, String> {
|
||||
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,
|
||||
|
||||
@@ -281,7 +281,7 @@ impl BrowserViewManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<PageState> {
|
||||
async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option<PageState> {
|
||||
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<PageState> {
|
||||
}
|
||||
|
||||
/// 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<PageState, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget) -> Result<PageState, String> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -255,15 +255,15 @@ fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||
}
|
||||
|
||||
let state = app.state::<crate::AppState>();
|
||||
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
|
||||
|
||||
@@ -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<String> {
|
||||
#[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.
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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<FetchedFile, String> {
|
||||
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.
|
||||
|
||||
@@ -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<Project, String> {
|
||||
}
|
||||
|
||||
/// `action` completes "Start the project before …", e.g. "saving this file".
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
|
||||
let container_id = project
|
||||
.container_id
|
||||
.clone()
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<ContainerTarget, String> {
|
||||
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<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
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<String> = 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<ViewerFile, String> {
|
||||
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<ViewerPoll, String> {
|
||||
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)]
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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<ViewerPoll, String> {
|
||||
pub async fn poll_file(
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
) -> Result<ViewerPoll, String> {
|
||||
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<Viewe
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
let (stdout, stderr, code) = ContainerTarget::docker(container_id)
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
|
||||
@@ -131,12 +131,12 @@ pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
pub async fn probe_candidates(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
candidates: &[String],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut cmd: Vec<String> = 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))
|
||||
|
||||
@@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String>
|
||||
}
|
||||
|
||||
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<SavedFile, String> {
|
||||
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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<SyncReport, String> {
|
||||
let target = ContainerTarget::docker(container_id);
|
||||
wait_until_ready(&target).await?;
|
||||
pub async fn sync_container(
|
||||
target: &ContainerTarget,
|
||||
payload: &Payload,
|
||||
) -> Result<SyncReport, String> {
|
||||
wait_until_ready(target).await?;
|
||||
|
||||
let (out, code) = target
|
||||
.exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])
|
||||
|
||||
Reference in new issue
Block a user