Files
Triple-C/app/src-tauri/src/browser_view/page.rs
T
shadow-testandClaude Opus 5 ab747ce53d
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Say what "open in container" is doing, and land on the pane doing it
Opening a page is a container probe, a browser launch, a page load and
often a viewer start — several seconds during which the only feedback
was the click itself. Worse from a terminal, where the result appears in
a pane the user is not looking at.

So: the backend emits progress on the existing `container-progress`
channel at each step, the Browser tab renders that line whenever it is
set — the progress belongs to the project, not to whoever pressed the
button, which is what lets a terminal-initiated open report anywhere at
all — and the terminal's "In container" now selects the project's
Browser tab before starting, so the line has somewhere to appear.

Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it
in local state, so `openProjectHomeTab` parks a request in the store and
the pane consumes it once. Consumed once, so it cannot fight the user's
own clicking afterwards.

Preview releases now prune themselves to the newest KEEP_PREVIEWS (2),
in a job that runs only if all three platforms published — a
half-finished run must not evict a good older build. The cleanup
workflow's manual sweep stays as the backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:13:21 -07:00

376 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
#[serde(default)]
pub viewport: Option<Viewport>,
#[serde(default)]
pub error: Option<String>,
}
/// 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<PageState, String> {
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<PageState> {
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<PageState, String> {
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());
}
}