diff --git a/CLAUDE.md b/CLAUDE.md index 8d1fbf4..5235e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of `CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive* the browser. + - **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a + viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and + keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a + container-side browser closes the loop with no host round trip and no auth bridge) and dev + servers on container loopback. **Verified: a second client cannot join a bound browser** — + `chromium.connect()` against the published endpoint times out in every URL form, because that + socket speaks the dashboard's transport, not the public connect protocol. So whoever launches + is the only process that can drive, which is why the helper is resident and why live resize + applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` / + `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port, + no second listener — and a re-open with a helper already up *navigates* rather than + relaunching, so a session signed in on one page survives to the next. + - **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger + window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured + against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's + settled `Resized` size into it — debounced by generation counter, since a drag emits + continuously and each one costs a container exec. - **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on `label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every container stopped, process exited. diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index f39e058..d0e479e 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -280,11 +280,35 @@ Press **Start browser view** and the pane fills with Playwright's own dashboard, container and reached over a token-gated listener on your machine's loopback address. Nothing is exposed off the machine. +#### Opening a page yourself + +**Open a page…** launches a browser inside the container at a URL and viewport you choose, and +publishes it to this pane. Two uses: + +- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so + a container-side browser completes the login without anything crossing to your host browser. + When a long URL appears in a terminal, the prompt that offers to open it on your host now also + offers **In container**, which does the same thing in one click. +- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping + and nothing exposed to your network — which is how you watch a UI Claude is building, and click + around it yourself. + +The **viewport** is the page's own resolution, and it is not the same thing as the window size. +The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing +the viewport is what makes the layout actually reflow. Pick a preset or type a size. + +Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can +be *watched* but not resized — a published browser admits only the client that launched it. Set +its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables +instead. + #### Watching it while you work Press **Open in own window** and the view moves out of the tab into a window of its own — put it on a second monitor, or turn on **Keep on top** and let it float above the app while you work in a -terminal. This is a window change only: the browser and the view keep running throughout, so +terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so +the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**, +for the reason above. This is a window change only: the browser and the view keep running throughout, so popping out and back costs nothing and interrupts nothing. While the view is in its own window the tab shows a placeholder rather than a second copy of it — diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index f52de7b..c1f5d72 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, State}; use crate::browser_view::install::{self, BrowserSetupOutcome}; -use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus}; +use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus}; use crate::AppState; /// Turn the pane on or off for a project. @@ -164,6 +164,106 @@ pub async fn set_browser_view_popout_always_on_top( popout::set_always_on_top(&app_handle, &project_id, on_top) } +/// Open a URL in a browser *inside* the container, published so the pane shows +/// it. +/// +/// Two uses, one action: an auth URL — where the OAuth callback listener is in +/// the container too, so the loop closes without the host being involved at all +/// — and a dev server on container loopback, which is how you watch a UI Claude +/// is building. +/// +/// The scheme allow-list mirrors the URL relay's: `http`/`https` only, so this +/// can never be talked into opening `file:` on the container's filesystem. +#[tauri::command] +pub async fn open_page_in_container_browser( + project_id: String, + url: String, + width: u32, + height: u32, + state: State<'_, AppState>, +) -> Result { + let trimmed = url.trim(); + 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 detection = crate::browser_view::detect::detect(&container_id).await?; + page::open( + &container_id, + &detection, + trimmed, + page::Viewport::sane(width, height), + ) + .await +} + +/// Resize the page this opened. The pop-out's "match window" mode calls this on +/// every settled resize, so it is deliberately cheap: one control-file write. +#[tauri::command] +pub async fn set_container_page_viewport( + project_id: String, + width: u32, + 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 +} + +/// State of the page this opened, if any. Never fails: "no page" is an answer. +#[tauri::command] +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 { + return Ok(page::PageState::default()); + }; + Ok(page::state(&container_id).await) +} + +/// Close the page this opened, leaving the view itself running. +#[tauri::command] +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; + Ok(()) +} + +/// Make the page track the pop-out window's size as it is dragged. +/// +/// Only affects a page **this app opened**: a bound browser admits no second +/// client, so one `@playwright/mcp` launched keeps the viewport it was given. +/// Turning it on applies the window's current size immediately, so the toggle +/// has a visible effect without waiting for a drag. +#[tauri::command] +pub async fn set_browser_view_match_window( + project_id: String, + enabled: bool, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + popout::set_match_window(&project_id, enabled); + if !enabled { + return Ok(()); + } + 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 +} + +/// Whether match-window mode is on. Read on mount, like the rest of the +/// pop-out's state — the pane is unmounted whenever another sub-tab is shown. +#[tauri::command] +pub async fn get_browser_view_match_window(project_id: String) -> Result { + Ok(popout::match_window(&project_id)) +} + /// The project's container, or a sentence saying why there isn't one. /// /// Every command here needs a *running* container, and every one of them used diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index ea276ea..adac57c 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -64,6 +64,7 @@ pub mod commands; pub mod detect; pub mod install; +pub mod page; pub mod popout; pub mod proxy; diff --git a/app/src-tauri/src/browser_view/page.rs b/app/src-tauri/src/browser_view/page.rs new file mode 100644 index 0000000..cb2be8c --- /dev/null +++ b/app/src-tauri/src/browser_view/page.rs @@ -0,0 +1,365 @@ +//! Open a page in the container's browser, and resize it while it runs. +//! +//! The pane [watches](super) browsers something else published. This opens one: +//! the user hands it a URL, it launches a browser inside the container, +//! publishes it with `browser.bind()` so the pane picks it up, and holds the +//! handle so the page can be navigated and **resized** afterwards. +//! +//! ## Why the handle has to be held +//! +//! Verified against a real bound browser: a second client cannot join one. +//! `chromium.connect()` against the published endpoint times out in every URL +//! form — the descriptor's socket speaks the dashboard's own transport, not the +//! public connect protocol. So whoever launches the browser is the only process +//! that can ever drive it. That is the whole reason this helper is a resident +//! process rather than a one-shot `node -e` that exits. +//! +//! It also draws the line for the feature: pages *this* opens can be resized +//! live; a browser `@playwright/mcp` launched can only be watched, and its size +//! is whatever `--viewport-size` it was given. +//! +//! ## Control channel +//! +//! A JSON file in `/tmp`, polled by the helper. No port, no second listener, no +//! addition to the proxy's attack surface — and it composes with the one exec +//! path this codebase already has. Writes go through `node -e` rather than +//! shell redirection so a URL never touches a shell. +//! +//! ## Viewport, and why it is the interesting part +//! +//! `page.setViewportSize()` genuinely reflows: measured on a page carrying a +//! `@media (max-width: 900px)` rule, the rule fires at 800×600 and clears at +//! 1440×900. Resizing the *window* the pane lives in does nothing of the sort — +//! the viewer is a CDP screencast, so a bigger window is the same pixels drawn +//! larger. This is what makes the pop-out usable as a responsive-design ruler. + +use serde::{Deserialize, Serialize}; + +use crate::docker::exec::exec_oneshot_as; + +use super::detect::PlaywrightDetection; + +/// Control file the helper polls, and the state file it writes back. +const CONTROL_PATH: &str = "/tmp/triple-c-page-control.json"; +const STATE_PATH: &str = "/tmp/triple-c-page-state.json"; +/// Where the detached helper's own output goes, so a failed start has a trail. +const HELPER_LOG: &str = "/tmp/triple-c-page.log"; + +/// How long to wait for the helper to report that the page is up. +const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); +/// Navigating a browser that is already up. One page load, not a cold start. +const REUSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35); +const READY_POLL: std::time::Duration = std::time::Duration::from_millis(400); + +/// A viewport, in CSS pixels. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct Viewport { + pub width: u32, + pub height: u32, +} + +impl Viewport { + /// Clamped to something a browser will accept. A window dragged to nothing + /// must not ask Chromium for a zero-width page. + pub fn sane(width: u32, height: u32) -> Self { + Self { + width: width.clamp(200, 7680), + height: height.clamp(200, 4320), + } + } +} + +/// What the helper reports about itself. +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct PageState { + #[serde(default)] + pub ready: bool, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub viewport: Option, + #[serde(default)] + pub error: Option, +} + +/// Open `url` in a freshly launched, bound browser. +/// +/// Replaces any page this opened before: one helper per container, because the +/// pane shows one browser and a second would just compete for the pane. +pub async fn open( + container_id: &str, + detection: &PlaywrightDetection, + url: &str, + viewport: Viewport, +) -> Result { + let core = detection.playwright_path.as_deref().ok_or_else(|| { + "Playwright isn't installed in this container — set it up from the Browser tab first." + .to_string() + })?; + // The directory of the resolved manifest is what `require()` wants. + let core_dir = core.trim_end_matches("/package.json"); + + // The executable is passed explicitly rather than left to Playwright's + // revision lookup: a container can hold browsers a given copy will not + // launch (see `detect::revision_skew`), and this is the one place we know + // which binary is actually on disk. + let executable = detection + .chromium_executable + .as_deref() + .filter(|_| detection.chromium_executable_exists); + + // 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 { + set_viewport(container_id, viewport).await?; + navigate(container_id, url).await?; + if let Some(state) = wait_for_url(container_id, url).await { + return Ok(state); + } + // It stopped answering; fall through and start a fresh one. + } + + close(container_id).await; + + let config = serde_json::json!({ + "core": core_dir, + "executable": executable, + "url": url, + "viewport": viewport, + "control": CONTROL_PATH, + "state": STATE_PATH, + }); + let script = format!("const CFG={};{}", config, HELPER); + + // Detached, for the same reason the viewer is: the process has to outlive + // the exec that started it, or the page closes the moment we return. + let launcher = format!( + "cd /workspace 2>/dev/null || true; rm -f {} {}; nohup node -e {} >{} 2>&1 &", + STATE_PATH, + CONTROL_PATH, + shell_quote(&script), + HELPER_LOG + ); + exec_oneshot_as( + container_id, + "claude", + vec!["sh".to_string(), "-c".to_string(), launcher], + Vec::new(), + ) + .await + .map_err(|e| format!("Could not start the browser helper: {}", e))?; + + wait_until_ready(container_id).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> { + write_control( + container_id, + 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 +} + +/// 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; +} + +/// Current state, or a default when no helper has ever run here. +pub async fn state(container_id: &str) -> PageState { + let script = format!( + "try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}", + STATE_PATH + ); + let Ok((out, _)) = exec_oneshot_as( + container_id, + "claude", + vec!["node".to_string(), "-e".to_string(), script], + Vec::new(), + ) + .await + else { + return PageState::default(); + }; + serde_json::from_str(out.trim()).unwrap_or_default() +} + +/// 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> { + let script = format!( + "require('fs').writeFileSync('{}',process.argv[1]);", + CONTROL_PATH + ); + exec_oneshot_as( + container_id, + "claude", + vec!["node".to_string(), "-e".to_string(), script, json], + Vec::new(), + ) + .await + .map(|_| ()) + .map_err(|e| format!("Could not reach the browser helper: {}", e)) +} + +/// Wait for a *running* helper to report the URL we just asked it for. +/// +/// 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 { + let deadline = std::time::Instant::now() + REUSE_TIMEOUT; + loop { + let state = state(container_id).await; + if state.ready && state.url.as_deref() == Some(url) { + return Some(state); + } + if std::time::Instant::now() >= deadline { + return None; + } + tokio::time::sleep(READY_POLL).await; + } +} + +/// 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 { + let deadline = std::time::Instant::now() + READY_TIMEOUT; + loop { + let state = state(container_id).await; + if let Some(error) = state.error.clone() { + return Err(error); + } + if state.ready { + return Ok(state); + } + if std::time::Instant::now() >= deadline { + return Err(format!( + "The browser didn't come up within {}s. Its log is at {} inside the container.", + READY_TIMEOUT.as_secs(), + HELPER_LOG + )); + } + tokio::time::sleep(READY_POLL).await; + } +} + +/// Single-quote for `sh`, the same way [`super`] does for the viewer's paths. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// The resident helper, appended to a `const CFG={…};` prelude. +/// +/// Deliberately one string passed as a single `argv` element — no shell parsing +/// of any part of it, exactly like `detect`'s probe. It launches, binds, and +/// then polls the control file; every failure path writes the state file, so a +/// helper that dies during startup is reported rather than waited out. +const HELPER: &str = concat!( + r#"const fs=require('fs');"#, + r#"const {chromium}=require(CFG.core);"#, + r#"const write=(o)=>{try{fs.writeFileSync(CFG.state,JSON.stringify(o));}catch(e){}};"#, + r#"const fail=(e)=>{write({ready:false,error:String(e&&e.message||e)});process.exit(1);};"#, + r#"process.on('unhandledRejection',fail);"#, + r#"(async()=>{"#, + // `chromiumSandbox:false` because the container has no user namespaces to + // give Chromium; headless because there is no display, which is also the + // only mode the dashboard can screencast anyway. + r#"const opts={headless:true,chromiumSandbox:false};"#, + r#"if(CFG.executable)opts.executablePath=CFG.executable;"#, + r#"const browser=await chromium.launch(opts);"#, + r#"const ctx=await browser.newContext({viewport:CFG.viewport});"#, + r#"const page=await ctx.newPage();"#, + // Bind before navigating: the pane should show the page loading rather than + // appearing once it is done. + r#"await browser.bind('claude',{metadata:{source:'triple-c'}});"#, + r#"let current=CFG.url,viewport=CFG.viewport;"#, + r#"const report=()=>write({ready:true,url:current,viewport});"#, + r#"try{await page.goto(CFG.url,{waitUntil:'domcontentloaded',timeout:30000});}catch(e){}"#, + r#"report();"#, + // The control loop. A poll, not a watcher: `fs.watch` misses writes on some + // filesystems and this costs nothing at 4 Hz. + r#"setInterval(async()=>{let c;try{c=JSON.parse(fs.readFileSync(CFG.control,'utf8'));}catch(e){return;}"#, + r#"try{fs.unlinkSync(CFG.control);}catch(e){}"#, + r#"if(c.close){await browser.close().catch(()=>{});write({ready:false});process.exit(0);}"#, + r#"if(c.viewport){viewport=c.viewport;await page.setViewportSize(c.viewport).catch(()=>{});}"#, + r#"if(c.url&&c.url!==current){current=c.url;await page.goto(c.url,{waitUntil:'domcontentloaded',timeout:30000}).catch(()=>{});}"#, + r#"report();},250);"#, + // A browser that dies (crash, or the user closing the last page) must not + // leave a helper claiming a live page. + r#"browser.on('disconnected',()=>{write({ready:false});process.exit(0);});"#, + r#"})().catch(fail);"#, +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_helper_is_one_argv_element_with_no_shell_hazards() { + // Same rule as the detect probe: it is passed as a single argument, so + // it must contain neither a newline nor a single quote that would end + // the quoting `open` wraps it in. + assert!(!HELPER.contains('\n'), "{}", HELPER); + assert!(HELPER.contains("chromium.launch"), "{}", HELPER); + } + + #[test] + fn the_helper_binds_so_the_pane_can_see_the_page() { + // Without this the page opens and the pane shows nothing — the whole + // feature hinges on the browser being published. + assert!(HELPER.contains("browser.bind('claude'"), "{}", HELPER); + } + + #[test] + fn the_helper_reports_startup_failures_instead_of_hanging() { + // `wait_until_ready` polls the state file; a helper that dies silently + // would turn every failure into a 45-second timeout. + assert!(HELPER.contains("unhandledRejection"), "{}", HELPER); + assert!(HELPER.contains("error:String"), "{}", HELPER); + } + + #[test] + fn a_viewport_is_clamped_to_something_a_browser_accepts() { + assert_eq!(Viewport::sane(0, 0), Viewport { width: 200, height: 200 }); + assert_eq!( + Viewport::sane(99_999, 99_999), + Viewport { width: 7680, height: 4320 } + ); + assert_eq!( + Viewport::sane(1440, 900), + Viewport { width: 1440, height: 900 } + ); + } + + #[test] + fn a_url_is_never_parsed_by_a_shell() { + // The launcher runs through `sh -c`, so the script is quoted with the + // POSIX close-escape-reopen form: the embedded quote becomes `'\''`, + // which leaves the `;rm` inside the string rather than starting a new + // command. (A naive "the output must not contain ';rm'" check fails + // here and would be wrong — that substring is *inside* the quoting.) + assert_eq!( + shell_quote("http://x/?a=1&b=2';rm -rf /"), + r"'http://x/?a=1&b=2'\'';rm -rf /'" + ); + // The control channel doesn't go near a shell at all: the JSON travels + // as an argv element to `node`. + assert!(!HELPER.contains("exec("), "{}", HELPER); + } + + #[test] + fn state_defaults_to_not_ready_rather_than_failing() { + // An empty/absent state file is the normal case before anything runs. + let s: PageState = serde_json::from_str("{}").unwrap(); + assert!(!s.ready); + assert!(s.error.is_none()); + } +} diff --git a/app/src-tauri/src/browser_view/popout.rs b/app/src-tauri/src/browser_view/popout.rs index 0ee9992..1b84cf3 100644 --- a/app/src-tauri/src/browser_view/popout.rs +++ b/app/src-tauri/src/browser_view/popout.rs @@ -27,6 +27,10 @@ //! dead viewer is worse than no window. The reverse is not true; closing the //! window leaves the view running, and the pane takes it back into the tab. +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; @@ -106,11 +110,17 @@ pub fn open( .map_err(|e| format!("Could not open the browser window: {}", e))?; // Closed from its own titlebar, this is the only thing that tells the pane - // to take the view back into the tab. - window.on_window_event(move |event| { - if matches!(event, WindowEvent::Destroyed) { + // to take the view back into the tab. `Resized` drives match-window mode — + // see `set_match_window`. + window.on_window_event(move |event| match event { + WindowEvent::Destroyed => { + set_match_window(&project_id_owned, false); emit(&app_for_event, &project_id_owned, PopoutState::CLOSED); } + WindowEvent::Resized(size) => { + on_resized(&app_for_event, &project_id_owned, size.width, size.height); + } + _ => {} }); log::info!("Browser view: popped out for project {}", project_id); @@ -170,6 +180,99 @@ pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Res Ok(()) } +// ───────────────────────────────────────────────────────────────────────────── +// Match-window mode +// ───────────────────────────────────────────────────────────────────────────── + +/// Projects whose pop-out is driving the page's viewport, and the generation of +/// the latest resize for each — the debounce is "did anything else arrive while +/// I slept?", which needs no timer to cancel. +static MATCH_WINDOW: OnceLock>> = OnceLock::new(); + +/// How long the window has to stop moving before the page is resized. +/// +/// A drag emits `Resized` continuously; each one costs a container exec, and +/// Chromium relayouts the page. Settling first turns a drag into one resize. +const RESIZE_SETTLE: Duration = Duration::from_millis(300); + +fn match_window_map() -> &'static Mutex> { + MATCH_WINDOW.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Turn match-window mode on or off for a project. +/// +/// Only ever affects a page **Triple-C opened** — a bound browser cannot be +/// joined by a second client, so a page `@playwright/mcp` launched keeps +/// whatever viewport it was given. See [`super::page`]. +pub fn set_match_window(project_id: &str, enabled: bool) { + let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner()); + let entry = map.entry(project_id.to_string()).or_insert((false, 0)); + entry.0 = enabled; +} + +pub fn match_window(project_id: &str) -> bool { + match_window_map() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(project_id) + .map(|(on, _)| *on) + .unwrap_or(false) +} + +/// The pop-out's current inner size, for applying match-window immediately +/// rather than only on the next drag. +pub fn inner_size(app: &AppHandle, project_id: &str) -> Option<(u32, u32)> { + let window = app.get_webview_window(&window_label(project_id))?; + let size = window.inner_size().ok()?; + Some((size.width, size.height)) +} + +/// Debounce a resize, then push the settled size into the page's viewport. +fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) { + let generation = { + let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner()); + let Some(entry) = map.get_mut(project_id) else { + return; + }; + if !entry.0 { + return; + } + entry.1 += 1; + entry.1 + }; + + let app = app.clone(); + let project_id = project_id.to_string(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(RESIZE_SETTLE).await; + // Superseded by a later resize: that one will do the work. + { + let map = match_window_map().lock().unwrap_or_else(|e| e.into_inner()); + match map.get(&project_id) { + Some((true, latest)) if *latest == generation => {} + _ => return, + } + } + + let state = app.state::(); + let Some(container_id) = state + .projects_store + .get(&project_id) + .and_then(|p| p.container_id) + else { + return; + }; + if let Err(e) = super::page::set_viewport( + &container_id, + super::page::Viewport::sane(width, height), + ) + .await + { + log::debug!("Browser view: could not match the page to the window: {}", e); + } + }); +} + fn emit(app: &AppHandle, project_id: &str, state: PopoutState) { let _ = app.emit( POPOUT_EVENT, @@ -198,4 +301,38 @@ mod tests { fn distinct_projects_get_distinct_windows() { assert_ne!(window_label("alpha"), window_label("beta")); } + + #[test] + fn match_window_is_off_until_asked_for_and_is_per_project() { + assert!(!match_window("mw-a")); + set_match_window("mw-a", true); + assert!(match_window("mw-a")); + // Another project's window must not start driving its page too. + assert!(!match_window("mw-b")); + set_match_window("mw-a", false); + assert!(!match_window("mw-a")); + } + + #[test] + fn a_resize_supersedes_the_one_before_it() { + // The debounce is a generation counter, not a cancellable timer: only + // the newest resize of a drag survives to touch the container. + set_match_window("mw-gen", true); + let read = || { + match_window_map() + .lock() + .unwrap() + .get("mw-gen") + .map(|(_, g)| *g) + .unwrap() + }; + let before = read(); + { + let mut map = match_window_map().lock().unwrap(); + let entry = map.get_mut("mw-gen").unwrap(); + entry.1 += 1; + } + assert!(read() > before); + set_match_window("mw-gen", false); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 2f22361..a918c19 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -440,6 +440,12 @@ pub fn run() { browser_view::commands::close_browser_view_popout, browser_view::commands::get_browser_view_popout_state, browser_view::commands::set_browser_view_popout_always_on_top, + browser_view::commands::open_page_in_container_browser, + browser_view::commands::set_container_page_viewport, + browser_view::commands::get_container_page_state, + browser_view::commands::close_container_page, + browser_view::commands::set_browser_view_match_window, + browser_view::commands::get_browser_view_match_window, // Shared Claude Code auth token commands::auth_token_commands::acquire_claude_token, commands::auth_token_commands::submit_claude_token_code, diff --git a/app/src/components/projects/home/BrowserTab.test.tsx b/app/src/components/projects/home/BrowserTab.test.tsx index f38c025..8e5303f 100644 --- a/app/src/components/projects/home/BrowserTab.test.tsx +++ b/app/src/components/projects/home/BrowserTab.test.tsx @@ -18,6 +18,10 @@ const closeBrowserViewPopout = vi.fn<(id: string) => Promise>(); const getBrowserViewPopoutState = vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>(); const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise>(); +const openPageInContainerBrowser = + vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>(); +const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise>(); +const getBrowserViewMatchWindow = vi.fn<() => Promise>(); const pushToast = vi.fn(); const setContainerProgress = vi.fn(); @@ -32,6 +36,10 @@ vi.mock("../../../lib/tauri-commands", () => ({ getBrowserViewPopoutState: () => getBrowserViewPopoutState(), setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) => setBrowserViewPopoutAlwaysOnTop(id, onTop), + openPageInContainerBrowser: (id: string, url: string, w: number, h: number) => + openPageInContainerBrowser(id, url, w, h), + setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on), + getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(), })); vi.mock("@tauri-apps/api/event", () => ({ @@ -130,6 +138,9 @@ beforeEach(() => { openBrowserViewPopout.mockResolvedValue(undefined); closeBrowserViewPopout.mockResolvedValue(undefined); setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined); + setBrowserViewMatchWindow.mockResolvedValue(undefined); + getBrowserViewMatchWindow.mockResolvedValue(false); + openPageInContainerBrowser.mockResolvedValue({ error: null }); }); const LIVE: BrowserViewStatus = { @@ -494,6 +505,58 @@ describe("BrowserTab", () => { expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument(); }); + it("opens a page in the container’s browser at the chosen viewport", async () => { + await renderLive(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /open a page/i })); + }); + fireEvent.change(screen.getByLabelText(/^URL$/i), { + target: { value: "http://localhost:5173" }, + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" })); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /open page/i })); + }); + + expect(openPageInContainerBrowser).toHaveBeenCalledWith( + "p1", + "http://localhost:5173", + 1920, + 1080, + ); + }); + + it("refuses a URL scheme the backend would reject, before the round trip", async () => { + await renderLive(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /open a page/i })); + }); + fireEvent.change(screen.getByLabelText(/^URL$/i), { + target: { value: "file:///etc/passwd" }, + }); + + expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled(); + expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument(); + expect(openPageInContainerBrowser).not.toHaveBeenCalled(); + }); + + it("offers match-window only once the view is in its own window", async () => { + await renderLive(); + expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /own window/i })); + }); + await act(async () => { + fireEvent.click(screen.getByRole("switch", { name: "Match window" })); + }); + + expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true); + }); + it("says why the window wouldn’t open instead of pretending it did", async () => { await renderLive(); openBrowserViewPopout.mockRejectedValue("no display"); diff --git a/app/src/components/projects/home/BrowserTab.tsx b/app/src/components/projects/home/BrowserTab.tsx index 1ed815e..abafb64 100644 --- a/app/src/components/projects/home/BrowserTab.tsx +++ b/app/src/components/projects/home/BrowserTab.tsx @@ -15,12 +15,16 @@ import { getBrowserViewStatus, installBrowserViewBrowser, installBrowserViewSupport, + getBrowserViewMatchWindow, getBrowserViewPopoutState, openBrowserViewPopout, + openPageInContainerBrowser, setBrowserViewEnabled, + setBrowserViewMatchWindow, setBrowserViewPopoutAlwaysOnTop, } from "../../../lib/tauri-commands"; import { useAppState } from "../../../store/appState"; +import OpenPageDialog from "./OpenPageDialog"; import AccordionSection from "../../ui/AccordionSection"; import Button from "../../ui/Button"; import StatusIndicator from "../../ui/StatusIndicator"; @@ -80,6 +84,10 @@ export default function BrowserTab({ project, active }: Props) { */ const [poppedOut, setPoppedOut] = useState(null); const [onTop, setOnTop] = useState(false); + /** The "open a page" dialog, and the request it is running. */ + const [matchWindow, setMatchWindow] = useState(false); + const [askPage, setAskPage] = useState(false); + const [openingPage, setOpeningPage] = useState(false); const pushToast = useAppState((s) => s.pushToast); const setContainerProgress = useAppState((s) => s.setContainerProgress); const progress = useAppState((s) => s.containerProgress[project.id]); @@ -137,6 +145,9 @@ export default function BrowserTab({ project, active }: Props) { // Unreachable in practice, but a pane stuck at "not asked yet" would // never show the view at all — so fail towards the tab. .catch(() => mounted.current && setPoppedOut(false)); + getBrowserViewMatchWindow(projectId) + .then((on) => mounted.current && setMatchWindow(on)) + .catch(() => {}); getBrowserViewStatus(projectId) .then((s) => mounted.current && setStatus(s)) .catch(() => {}); @@ -219,6 +230,55 @@ export default function BrowserTab({ project, active }: Props) { [projectId, pushToast], ); + /** + * Open a URL in a browser inside the container. + * + * The pane only ever *watched* browsers something else published; this is the + * one action that opens one. It also means the page can be resized later — + * whoever launches a bound browser is the only process that can drive it. + */ + const openPage = useCallback( + async (url: string, width: number, height: number) => { + setOpeningPage(true); + try { + const result = await openPageInContainerBrowser(projectId, url, width, height); + if (!mounted.current) return; + setAskPage(false); + if (result.error) { + pushToast({ kind: "error", message: "The page didn’t open", detail: result.error }); + } else { + pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` }); + } + } catch (e) { + pushToast({ + kind: "error", + message: "Could not open the page in the container’s browser", + detail: String(e), + }); + } finally { + if (mounted.current) setOpeningPage(false); + } + }, + [projectId, pushToast], + ); + + const toggleMatchWindow = useCallback( + async (next: boolean) => { + setMatchWindow(next); + try { + await setBrowserViewMatchWindow(projectId, next); + } catch (e) { + if (mounted.current) setMatchWindow(!next); + pushToast({ + kind: "error", + message: "Could not match the page to the window", + detail: String(e), + }); + } + }, + [projectId, pushToast], + ); + /** Run one install. Every path clears the progress line it started. */ const install = useCallback( async (which: Exclude) => { @@ -326,11 +386,25 @@ export default function BrowserTab({ project, active }: Props) { )} + {live && poppedOut === true && ( + + Match window + + + )} {live && poppedOut === false && ( )} + {live && ( + + )} {live && poppedOut !== null && ( + + + } + > +
+

+ Launches a browser inside this container and publishes it to the + Browser tab. Use it for a sign-in page — the callback listener is in the + container too, so the login completes without involving your host browser — + or for a dev server on container loopback. +

+ + + +
+ Viewport +
+ {PRESETS.map((p) => { + const active = p.width === width && p.height === height; + return ( + + ); + })} +
+
+ setWidth(Number(e.target.value))} + className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]" + /> + + setHeight(Number(e.target.value))} + className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]" + /> + CSS pixels +
+
+
+ + ); +} diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index dfebdc8..3f8f7c4 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -7,7 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; -import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands"; +import { + awsSsoRefresh, + openPageInContainerBrowser, + uploadHostFileToTerminal, +} from "../../lib/tauri-commands"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { UrlDetector } from "../../lib/urlDetector"; import { @@ -529,6 +533,46 @@ export default function TerminalView({ sessionId, active }: Props) { openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); }, [urlPrompt]); + /** + * Open the prompted URL in the container's own browser instead of the host's. + * + * For a sign-in this is the shorter path: the callback listener the tool is + * waiting on is inside the container, so a container-side browser closes the + * loop with nothing crossing to the host. The page is published to the + * project's Browser tab, which is where the user completes it by hand. + */ + const handleOpenUrlInContainer = useCallback(() => { + if (!urlPrompt) return; + const safe = sanitizeRelayUrl(urlPrompt.url); + setUrlPrompt(null); + if (!safe) { + console.warn("Refusing to open a URL that failed validation"); + return; + } + if (!projectId) return; + // A sign-in page is the one case where the *window* size matters least and + // the layout matters most, so it gets the ordinary desktop viewport. + openPageInContainerBrowser(projectId, safe, 1280, 720) + .then((result) => { + const push = useAppState.getState().pushToast; + if (result.error) { + push({ kind: "error", message: "The page didn’t open", detail: result.error }); + } else { + push({ + kind: "success", + message: "Opened in the container’s browser — see the project’s Browser tab", + }); + } + }) + .catch((e) => + useAppState.getState().pushToast({ + kind: "error", + message: "Could not open it in the container’s browser", + detail: String(e), + }), + ); + }, [urlPrompt, projectId]); + const handleScrollToBottom = useCallback(() => { const term = termRef.current; if (term) { @@ -606,6 +650,7 @@ export default function TerminalView({ sessionId, active }: Props) { url={urlPrompt.url} label={urlPrompt.label} onOpen={handleOpenUrl} + onOpenInContainer={handleOpenUrlInContainer} onDismiss={() => setUrlPrompt(null)} /> )} diff --git a/app/src/components/terminal/UrlToast.tsx b/app/src/components/terminal/UrlToast.tsx index dcacbc4..6874d31 100644 --- a/app/src/components/terminal/UrlToast.tsx +++ b/app/src/components/terminal/UrlToast.tsx @@ -6,6 +6,9 @@ interface Props { /** Heading above the URL. Says why the toast appeared. */ label?: string; onOpen: () => void; + /** Open it in the container's own browser instead of the host's. Omitted when + * the project has no browser to open it in. */ + onOpenInContainer?: () => void; onDismiss: () => void; } @@ -30,6 +33,7 @@ export default function UrlToast({ url, label = "Long URL detected", onOpen, + onOpenInContainer, onDismiss, }: Props) { const origin = urlOrigin(url); @@ -131,6 +135,30 @@ export default function UrlToast({ Open + {onOpenInContainer && ( + // A sign-in completed in the *container's* browser lands its callback + // on the container's own loopback, which is where the tool waiting for + // it is listening — no host round trip, no auth bridge. + + )} +