//! 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 tauri::AppHandle; use crate::commands::project_commands::emit_progress; 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( app: &AppHandle, project_id: &str, 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 { 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 { return Ok(state); } // It stopped answering; fall through and start a fresh one. } close(container_id).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. emit_progress(app, project_id, "Launching a browser in the container…"); 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))?; emit_progress(app, project_id, "Waiting for the page to load…"); 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()); } }