Compare commits

...
2 Commits
Author SHA1 Message Date
shadow-testandClaude Opus 5 f68d9c5788 Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The pane could only ever watch a browser something else had published.
This opens one: a URL and a viewport, launched inside the container and
bound so the pane picks it up. Two uses, one action — a sign-in page,
where the callback listener is *in* the container and the loop closes
with no host round trip and no auth bridge, and a dev server on container
loopback, which is how you watch a UI Claude is building.

Reachable from both places the question comes up: "Open a page…" in the
Browser tab, and an "In container" button on the terminal's URL prompt.

Verified first, because it decided the design: a second client cannot
join a bound browser. `chromium.connect()` against the published endpoint
times out in every URL form (`ws+unix://…`, with and without the trailing
path) — that socket speaks the dashboard's own transport, not the public
connect protocol. Whoever launches is therefore the only process that can
drive, so the helper is resident and holds the handle, and live resize
applies to pages we opened and never to `@playwright/mcp`'s. Those take
`--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the
docs now say.

The viewport is the interesting half. Resizing the *window* does nothing
to the page — the viewer is a CDP screencast, so a bigger window is the
same pixels drawn larger, which is why pages have been looking like they
were rendered small. `page.setViewportSize()` genuinely reflows: measured
against a `@media (max-width: 900px)` rule, it fires at 800×600 and
clears at 1440×900. Match-window mode pushes the pop-out's settled size
into it, debounced by generation counter because a drag emits `Resized`
continuously and each one costs a container exec.

Control is a polled JSON file in /tmp: no port, no second listener,
nothing added to the proxy's surface, and URLs travel as argv to `node`
so no shell ever parses one. A re-open with a helper already up
navigates instead of relaunching — otherwise the second page would throw
away the session the first one just signed into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:15:12 -07:00
shadow-testandClaude Opus 5 bd72781482 Install one Playwright tree, and notice when a container has two
Setup installed `playwright@latest` and `@playwright/cli@latest` together.
Verified on a real container, that produces a tree that looks right and is
broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm
hoists it, and `playwright@latest` (1.62.1) nests its own
`playwright-core@1.62.1` beside it. The two cores want different browser
revisions.

The browser step runs the *resolved* — hoisted — CLI, so it downloads
chromium-1237. Every script Claude writes says `require("playwright")`,
gets the nested 1.62.1, and dies with:

  Executable doesn't exist at …/chromium_headless_shell-1234/…

while the pane reports a browser installed, because one is. This is
deterministic, not bad luck: every container set up through the pane
lands in it.

So the viewer package is installed first, and the `playwright` version
installed after it is the one that package pins — read from the manifest
npm just wrote, falling back to `@latest` only if it cannot be read. One
core, one browser revision, both halves agreeing. Re-running "Set up
Playwright" repairs an already-split tree.

Detection now asks the question directly rather than listing a cache: it
asks each resolved copy for `chromium.executablePath()` and whether that
file exists — the viewer's copy *and* the one `require("playwright")`
returns, since those are routinely different. `needs_browser()` covers
"installed but not launchable", and the pane names both halves instead of
saying "install a browser" over a cache that visibly has one.

An absent field is "the probe didn't answer", never "skewed": containers
predating these fields must not be told their browsers are wrong. The
Rust side gets that from Option; the TypeScript mirror needed `!= null`,
which an existing test caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:04:06 -07:00
16 changed files with 1505 additions and 61 deletions
+17
View File
@@ -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 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* `CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
the browser. 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 - **`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 `label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
container stopped, process exited. container stopped, process exited.
+25 -1
View File
@@ -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 container and reached over a token-gated listener on your machine's loopback address. Nothing is
exposed off the machine. 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 #### 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 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 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. 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 — While the view is in its own window the tab shows a placeholder rather than a second copy of it —
+101 -1
View File
@@ -5,7 +5,7 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use crate::browser_view::install::{self, BrowserSetupOutcome}; 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; use crate::AppState;
/// Turn the pane on or off for a project. /// 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) 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<page::PageState, String> {
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<page::PageState, String> {
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<bool, String> {
Ok(popout::match_window(&project_id))
}
/// The project's container, or a sentence saying why there isn't one. /// 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 /// Every command here needs a *running* container, and every one of them used
+180 -1
View File
@@ -93,6 +93,32 @@ pub struct PlaywrightDetection {
/// user's own scripts and not for the MCP plugin. /// user's own scripts and not for the MCP plugin.
#[serde(default)] #[serde(default)]
pub chrome_channel: Option<String>, pub chrome_channel: Option<String>,
/// The Chromium binary the *resolved* Playwright would launch, asked of the
/// build itself rather than derived from the cache listing.
#[serde(default)]
pub chromium_executable: Option<String>,
/// Whether that binary is actually on disk.
///
/// False with a non-empty [`Self::browsers`] is the revision-skew case: two
/// Playwright copies in one container pin different revisions, so the cache
/// can be full of browsers and every launch still fail.
#[serde(default)]
pub chromium_executable_exists: bool,
/// The version a *script's* `require("playwright")` resolves to.
///
/// Tracked separately from [`Self::playwright_version`] because they are
/// routinely different in one directory: `@playwright/cli` pins its own
/// `playwright-core`, npm hoists that, and a separately-installed
/// `playwright` then nests a second core beside it. The viewer uses one,
/// Claude's scripts use the other.
#[serde(default)]
pub script_playwright_version: Option<String>,
/// The Chromium that copy would launch, and whether it is there. This is
/// the pair that decides whether a script Claude writes actually runs.
#[serde(default)]
pub script_chromium_executable: Option<String>,
#[serde(default)]
pub script_chromium_executable_exists: bool,
/// Where the probe looked, echoed back for the "not found" message. /// Where the probe looked, echoed back for the "not found" message.
#[serde(default)] #[serde(default)]
pub searched: Vec<String>, pub searched: Vec<String>,
@@ -155,13 +181,82 @@ impl PlaywrightDetection {
None None
} }
/// The revision-skew sentence, for the pane's browser step.
///
/// Separate from [`Self::blocker`] because it does not block the *viewer* —
/// the dashboard runs fine; it is the browser that cannot start. Names both
/// halves, because "install a browser" over a cache that visibly already
/// has one reads as nonsense without them.
pub fn skew_message(&self) -> Option<String> {
if !self.revision_skew() {
return None;
}
// Which half is broken changes what the user sees, so say the one that
// is. The scripts case is the one that looks like a lie: the pane is
// green, the viewer works, and every script Claude writes dies.
if self.scripts_cannot_launch() {
return Some(format!(
"This container has {}, and the viewer works — but `require(\"playwright\")` \
resolves Playwright {}, which launches {}. That file isn't there, so every \
script Claude writes fails with “Executable doesn't exist”. Two copies ended \
up in one tree: `@playwright/cli` pins its own `playwright-core`, and a \
separately-installed `playwright` nests a second one beside it. “Set up \
Playwright” below reinstalls them as one consistent set.",
self.browsers.join(", "),
self.script_playwright_version.as_deref().unwrap_or("?"),
self.script_chromium_executable.as_deref().unwrap_or("?"),
));
}
Some(format!(
"This container has {}, but Playwright {} launches {} — which isn't there, so \
every `chromium.launch()` fails with “Executable doesn't exist”. That happens \
when two Playwright copies share a container (typically an npx `@playwright/mcp` \
alongside this one); each pins its own browser revision. “Install Chromium” below \
fetches the revision this build needs — it runs that build's own installer, so it \
cannot pick the wrong one again.",
self.browsers.join(", "),
self.playwright_version.as_deref().unwrap_or("?"),
self.chromium_executable.as_deref().unwrap_or("?"),
))
}
/// Whether Playwright is present but has no browser at all to drive — /// Whether Playwright is present but has no browser at all to drive —
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer /// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
/// still runs, it just has nothing to show until a browser is bound. /// still runs, it just has nothing to show until a browser is bound.
pub fn needs_browser(&self) -> bool { pub fn needs_browser(&self) -> bool {
self.playwright_version.is_some() self.playwright_version.is_some()
&& self.browsers.is_empty()
&& self.chrome_channel.is_none() && self.chrome_channel.is_none()
&& (self.browsers.is_empty() || self.revision_skew())
}
/// Browsers are installed, but not the revision this Playwright launches.
///
/// The container looks equipped and every `chromium.launch()` fails with
/// "Executable doesn't exist". It happens whenever two Playwright copies
/// share a container — the npx `@playwright/mcp` one and a `/workspace`
/// one — because each pins its own revision and installs into the same
/// cache. The install action fixes it: it runs the *resolved* build's own
/// CLI, so it fetches exactly the revision that was missing.
///
/// Requires the probe to have answered: an older container image, or a
/// Playwright too broken to `require`, leaves `chromium_executable` unset,
/// and "didn't answer" must not read as "skewed".
pub fn revision_skew(&self) -> bool {
!self.browsers.is_empty() && (self.viewer_cannot_launch() || self.scripts_cannot_launch())
}
/// The copy serving the viewer would not find its browser.
fn viewer_cannot_launch(&self) -> bool {
self.chromium_executable.is_some() && !self.chromium_executable_exists
}
/// `require("playwright")` — what every script Claude writes uses — would
/// not find its browser. Independent of the above, and the more common of
/// the two: `@playwright/cli` pins a `playwright-core`, npm hoists it, and
/// a separately-installed `playwright` nests a second one that no browser
/// was ever downloaded for.
fn scripts_cannot_launch(&self) -> bool {
self.script_chromium_executable.is_some() && !self.script_chromium_executable_exists
} }
/// The searched roots as prose, so a message never trails off into "Looked /// The searched roots as prose, so a message never trails off into "Looked
@@ -277,6 +372,30 @@ const PROBE: &str = concat!(
// the pane claim a browser is present when none is. // the pane claim a browser is present when none is.
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#, r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#, r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
// What this Playwright would *actually launch*, and whether it is there.
//
// A cache listing is not the same question. Two Playwright copies in one
// container — the npx `@playwright/mcp` one and a `/workspace` one — pin
// different browser revisions, and each installs its own. So the cache can
// hold `chromium-1237` while the resolved build wants `chromium-1234` and
// every `chromium.launch()` dies with "Executable doesn't exist", *while
// the pane reports a browser installed*. Asking the build itself sidesteps
// revision arithmetic entirely: this is the path a launch would use.
r#"const exe=(dir)=>{try{const bt=require(dir).chromium;"#,
r#"const ep=bt&&bt.executablePath?bt.executablePath():null;"#,
r#"return ep?[ep,fs.existsSync(ep)]:null;}catch(e){return null;}};"#,
r#"if(core){const r=exe(path.dirname(core));"#,
r#"if(r){out.chromium_executable=r[0];out.chromium_executable_exists=r[1];}}"#,
// And separately: what a *script* gets. `require("playwright")` is what
// every Playwright example writes, and it resolves the wrapper — which
// carries its own nested `playwright-core` whenever npm could not settle on
// one version. That copy can want a different browser revision than the one
// the viewer's copy installed, so it is asked its own question.
r#"try{const w=res("playwright/package.json");"#,
r#"if(w){const j=JSON.parse(fs.readFileSync(w,"utf8"));out.script_playwright_version=j.version;"#,
r#"const wc=at("playwright-core/package.json",path.dirname(w));"#,
r#"const r=exe(path.dirname(wc||w));"#,
r#"if(r){out.script_chromium_executable=r[0];out.script_chromium_executable_exists=r[1];}}}catch(e){}"#,
// The Chrome *channel* is an apt package, not a Playwright download, so it // The Chrome *channel* is an apt package, not a Playwright download, so it
// is looked for where apt puts it. // is looked for where apt puts it.
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#, r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
@@ -467,6 +586,66 @@ mod tests {
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE); assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
} }
#[test]
fn the_probe_asks_playwright_what_it_would_launch() {
// Not derived from the cache listing — asked of the build, because the
// cache can hold a browser this build will never launch.
assert!(PROBE.contains("executablePath"), "{}", PROBE);
assert!(PROBE.contains("out.chromium_executable_exists"), "{}", PROBE);
}
/// A container carrying browsers from a *different* Playwright copy.
fn skewed() -> PlaywrightDetection {
parse_probe_output(&payload(concat!(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"],"#,
r#""chromium_executable":"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome","#,
r#""chromium_executable_exists":false}"#,
)))
.unwrap()
}
#[test]
fn a_browser_cache_full_of_the_wrong_revision_counts_as_no_browser() {
let d = skewed();
// The viewer still serves — it is the browser that cannot start.
assert!(d.is_usable());
assert_eq!(d.blocker(), None);
assert!(d.revision_skew());
assert!(d.needs_browser(), "a browser that cannot launch is not a browser");
}
#[test]
fn the_skew_message_names_both_revisions_and_the_way_out() {
let msg = skewed().skew_message().unwrap();
assert!(msg.contains("chromium-1237"), "{}", msg); // what is there
assert!(msg.contains("chromium-1234"), "{}", msg); // what it wants
assert!(msg.contains("Install Chromium"), "{}", msg); // what fixes it
}
#[test]
fn the_chrome_channel_covers_a_skewed_cache() {
// The channel is an apt binary at a fixed path, so a revision mismatch
// cannot affect it: there is still something to drive.
let mut d = skewed();
d.chrome_channel = Some("/usr/bin/google-chrome-stable".to_string());
assert!(!d.needs_browser());
}
#[test]
fn a_probe_that_could_not_answer_is_not_reported_as_skew() {
// Older container, or a Playwright too broken to `require`: unset is
// "unknown", and unknown must never render as "your browsers are wrong".
let d = parse_probe_output(&payload(concat!(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"]}"#,
)))
.unwrap();
assert!(!d.revision_skew());
assert!(!d.needs_browser());
assert_eq!(d.skew_message(), None);
}
#[test] #[test]
fn a_missing_viewer_package_is_reported_separately() { fn a_missing_viewer_package_is_reported_separately() {
let d = parse_probe_output(&payload( let d = parse_probe_output(&payload(
+145 -46
View File
@@ -73,14 +73,33 @@ use crate::docker::exec::{
use super::detect::{self, PlaywrightDetection}; use super::detect::{self, PlaywrightDetection};
/// The two packages the pane genuinely needs, pinned to `@latest` because /// The viewer package — installed **first**, and it decides the version of
/// `browser.bind()` is recent and the viewer tracks it. /// `playwright` installed after it.
/// ///
/// This is the *minimum* set. A user who followed the old guidance ended up /// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
/// with a global install as well as these; only these are required. Note what /// configuration to make, and it contributes nothing to serving a viewer.
/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not ///
/// this pane's, and it contributes nothing to serving a viewer. /// **Order matters here, and `playwright` is deliberately not `@latest`.**
pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@latest"]; ///
/// Installing both at `@latest` produces a tree that looks right and is broken.
/// Verified on a real container: `@playwright/cli@0.1.18` pins
/// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and
/// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1`
/// beside it. The two cores want *different browser revisions*. The browser
/// step runs the resolved — hoisted — CLI, so it downloads 1237; every script
/// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies
/// with "Executable doesn't exist … chromium_headless_shell-1234". The pane
/// meanwhile reports a browser installed, because one is.
///
/// So the viewer package goes first and its own pinned `playwright` version is
/// what gets installed second — one core, one browser revision, both halves
/// agreeing. See [`pinned_playwright_spec`].
pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest";
/// Fallback when the viewer's manifest can't be read: better a possibly-skewed
/// tree than no Playwright at all, and [`detect`](super::detect) reports the
/// skew either way.
pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest";
/// Where the packages are installed. Container storage, not a bind mount — see /// Where the packages are installed. Container storage, not a bind mount — see
/// the module docs. /// the module docs.
@@ -213,49 +232,33 @@ pub async fn install_packages(
emit_progress( emit_progress(
app, app,
project_id, project_id,
&format!( &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
"Installing playwright and @playwright/cli into {}/node_modules…",
INSTALL_DIR
),
); );
// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
// involved. The guard matters because these are `@latest`: current
// Playwright has no postinstall (verified — `playwright@1.62.1` declares no
// `scripts` at all), but if a future release brings the browser download
// back, this step must stay small and the download must stay the step the
// user explicitly asked for.
let mut cmd = vec![
"env".to_string(),
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
"npm".to_string(),
"install".to_string(),
// Leaves any package.json and lockfile at /workspace untouched.
"--no-save".to_string(),
"--no-fund".to_string(),
"--no-audit".to_string(),
];
cmd.extend(PACKAGES.iter().map(|p| p.to_string()));
let step = run_step(
app,
project_id,
container_id,
"claude",
INSTALL_DIR,
cmd,
NPM_TIMEOUT,
)
.await?;
if step.exit_code != 0 { if step.exit_code != 0 {
return Err(format!( return Err(format!(
"npm couldn't install Playwright in this container (exit {}).\n\nnpm said:\n{}", "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
step.exit_code, step.exit_code,
step.log_or("it produced no output at all") step.log_or("it produced no output at all")
)); ));
} }
// Second, `playwright` at the version the viewer package pins — see
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
let spec = pinned_playwright_spec(container_id).await;
emit_progress(app, project_id, &format!("Installing {}…", spec));
let second = npm_install(app, project_id, container_id, &spec).await?;
if second.exit_code != 0 {
return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
spec,
second.exit_code,
second.log_or("it produced no output at all")
));
}
step.log = merge_logs(step.log, second.log);
emit_progress(app, project_id, "Re-checking what the container has…"); emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect(container_id).await?; let detection = detect::detect(container_id).await?;
@@ -265,7 +268,12 @@ pub async fn install_packages(
// saying so here is what stops someone walking away from a pane that will // saying so here is what stops someone walking away from a pane that will
// never show them anything. // never show them anything.
let mut warning = detection.blocker(); let mut warning = detection.blocker();
if detection.needs_browser() { // Skew outranks "no browser": a container in that state *has* browsers, and
// telling someone to install one they can see already installed is how a
// real user ends up doing it three times.
if let Some(skew) = detection.skew_message() {
warning = merge(warning, skew);
} else if detection.needs_browser() {
warning = merge( warning = merge(
warning, warning,
"Playwright is installed, but this container has no browser to drive yet. Install \ "Playwright is installed, but this container has no browser to drive yet. Install \
@@ -282,6 +290,87 @@ pub async fn install_packages(
}) })
} }
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`.
///
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
/// involved. The guard matters because these are `@latest`: current Playwright
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
/// all), but if a future release brings the browser download back, this step
/// must stay small and the download must stay the step the user asked for.
async fn npm_install(
app: &AppHandle,
project_id: &str,
container_id: &str,
spec: &str,
) -> Result<StepResult, String> {
let cmd = vec![
"env".to_string(),
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
"npm".to_string(),
"install".to_string(),
// Leaves any package.json and lockfile at /workspace untouched.
"--no-save".to_string(),
"--no-fund".to_string(),
"--no-audit".to_string(),
spec.to_string(),
];
run_step(
app,
project_id,
container_id,
"claude",
INSTALL_DIR,
cmd,
NPM_TIMEOUT,
)
.await
}
/// The `playwright` spec to install: the exact version `@playwright/cli`
/// depends on, so both halves share one `playwright-core`.
///
/// Read from the manifest npm just wrote rather than guessed, and falling back
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
/// install something, not nothing.
async fn pinned_playwright_spec(container_id: &str) -> String {
let script = format!(
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
process.stdout.write(d.playwright||'');}}catch(e){{}}",
INSTALL_DIR
);
let (out, _code) = exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
.unwrap_or_default();
let version: &str = out.trim();
// A version, not a range or a URL: anything else goes to the fallback
// rather than into an npm command line.
if !version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
{
format!("playwright@{}", version)
} else {
PLAYWRIGHT_FALLBACK.to_string()
}
}
/// Keep both npm runs' output, so a failure in either is diagnosable.
fn merge_logs(first: String, second: String) -> String {
match (first.trim().is_empty(), second.trim().is_empty()) {
(true, _) => second,
(_, true) => first,
_ => format!("{}\n{}", first.trim_end(), second),
}
}
/// Install a browser: its system libraries first, then the browser, then prove /// Install a browser: its system libraries first, then the browser, then prove
/// one actually starts. /// one actually starts.
/// ///
@@ -865,10 +954,20 @@ mod tests {
fn the_package_set_is_the_minimum_that_satisfies_the_probe() { fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
// The viewer package is not optional, and `@playwright/mcp` is not a // The viewer package is not optional, and `@playwright/mcp` is not a
// member: it can bind sessions, it can never serve the UI. // member: it can bind sessions, it can never serve the UI.
assert!(PACKAGES.iter().any(|p| p.starts_with("playwright@"))); assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@"))); assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp"))); assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
assert_eq!(PACKAGES.len(), 2); }
#[test]
fn playwright_is_not_installed_at_latest_alongside_the_viewer() {
// `@latest` for both is exactly what splits the tree into two
// `playwright-core`s wanting different browser revisions — the viewer
// green, every `require("playwright")` dead. The version comes from the
// viewer's own manifest instead; `@latest` is only the fallback for an
// unreadable one.
assert!(!VIEWER_PACKAGE.contains("playwright@latest"));
assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest");
} }
#[test] #[test]
+1
View File
@@ -64,6 +64,7 @@
pub mod commands; pub mod commands;
pub mod detect; pub mod detect;
pub mod install; pub mod install;
pub mod page;
pub mod popout; pub mod popout;
pub mod proxy; pub mod proxy;
+365
View File
@@ -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<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(
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 {
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<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());
}
}
+140 -3
View File
@@ -27,6 +27,10 @@
//! dead viewer is worse than no window. The reverse is not true; closing the //! 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. //! 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 serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; 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))?; .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 // Closed from its own titlebar, this is the only thing that tells the pane
// to take the view back into the tab. // to take the view back into the tab. `Resized` drives match-window mode —
window.on_window_event(move |event| { // see `set_match_window`.
if matches!(event, WindowEvent::Destroyed) { 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); 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); 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(()) 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<Mutex<HashMap<String, (bool, u64)>>> = 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<HashMap<String, (bool, u64)>> {
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::<crate::AppState>();
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) { fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
let _ = app.emit( let _ = app.emit(
POPOUT_EVENT, POPOUT_EVENT,
@@ -198,4 +301,38 @@ mod tests {
fn distinct_projects_get_distinct_windows() { fn distinct_projects_get_distinct_windows() {
assert_ne!(window_label("alpha"), window_label("beta")); 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);
}
} }
+6
View File
@@ -440,6 +440,12 @@ pub fn run() {
browser_view::commands::close_browser_view_popout, browser_view::commands::close_browser_view_popout,
browser_view::commands::get_browser_view_popout_state, browser_view::commands::get_browser_view_popout_state,
browser_view::commands::set_browser_view_popout_always_on_top, 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 // Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token, commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code, commands::auth_token_commands::submit_claude_token_code,
@@ -18,6 +18,10 @@ const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
const getBrowserViewPopoutState = const getBrowserViewPopoutState =
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>(); vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>(); const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
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<void>>();
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
const pushToast = vi.fn(); const pushToast = vi.fn();
const setContainerProgress = vi.fn(); const setContainerProgress = vi.fn();
@@ -32,6 +36,10 @@ vi.mock("../../../lib/tauri-commands", () => ({
getBrowserViewPopoutState: () => getBrowserViewPopoutState(), getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) => setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
setBrowserViewPopoutAlwaysOnTop(id, onTop), 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", () => ({ vi.mock("@tauri-apps/api/event", () => ({
@@ -69,6 +77,11 @@ const NOTHING: PlaywrightDetection = {
cli_entry: null, cli_entry: null,
browsers: [], browsers: [],
chrome_channel: null, chrome_channel: null,
chromium_executable: null,
chromium_executable_exists: false,
script_playwright_version: null,
script_chromium_executable: null,
script_chromium_executable_exists: false,
searched: [ searched: [
"/workspace", "/workspace",
"/usr/lib/node_modules", "/usr/lib/node_modules",
@@ -125,6 +138,9 @@ beforeEach(() => {
openBrowserViewPopout.mockResolvedValue(undefined); openBrowserViewPopout.mockResolvedValue(undefined);
closeBrowserViewPopout.mockResolvedValue(undefined); closeBrowserViewPopout.mockResolvedValue(undefined);
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined); setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
setBrowserViewMatchWindow.mockResolvedValue(undefined);
getBrowserViewMatchWindow.mockResolvedValue(false);
openPageInContainerBrowser.mockResolvedValue({ error: null });
}); });
const LIVE: BrowserViewStatus = { const LIVE: BrowserViewStatus = {
@@ -362,6 +378,41 @@ describe("BrowserTab", () => {
expect(screen.queryByTitle(/browser view for/i)).toBeNull(); expect(screen.queryByTitle(/browser view for/i)).toBeNull();
}); });
it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => {
// The cache is full and every script fails \u2014 "install a browser" alone
// would read as nonsense, so the copy has to say which copy wants what.
checkBrowserViewSupport.mockResolvedValue({
...READY,
browsers: ["chromium-1237"],
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome",
chromium_executable_exists: true,
script_playwright_version: "1.62.1",
script_chromium_executable:
"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
script_chromium_executable_exists: false,
});
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument();
// Both revisions appear in the explanation: what is installed, and what
// the failing copy actually wants.
expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0);
});
it("does not call an unanswered probe a skew", async () => {
// A container older than these fields omits them; unknown is not broken.
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull();
});
it("only offers a window of its own once there is something to watch", async () => { it("only offers a window of its own once there is something to watch", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
render(<BrowserTab project={project} active />); render(<BrowserTab project={project} active />);
@@ -454,6 +505,58 @@ describe("BrowserTab", () => {
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument(); 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 () => { it("says why the window wouldn’t open instead of pretending it did", async () => {
await renderLive(); await renderLive();
openBrowserViewPopout.mockRejectedValue("no display"); openBrowserViewPopout.mockRejectedValue("no display");
+134 -1
View File
@@ -15,12 +15,16 @@ import {
getBrowserViewStatus, getBrowserViewStatus,
installBrowserViewBrowser, installBrowserViewBrowser,
installBrowserViewSupport, installBrowserViewSupport,
getBrowserViewMatchWindow,
getBrowserViewPopoutState, getBrowserViewPopoutState,
openBrowserViewPopout, openBrowserViewPopout,
openPageInContainerBrowser,
setBrowserViewEnabled, setBrowserViewEnabled,
setBrowserViewMatchWindow,
setBrowserViewPopoutAlwaysOnTop, setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands"; } from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState"; import { useAppState } from "../../../store/appState";
import OpenPageDialog from "./OpenPageDialog";
import AccordionSection from "../../ui/AccordionSection"; import AccordionSection from "../../ui/AccordionSection";
import Button from "../../ui/Button"; import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator"; import StatusIndicator from "../../ui/StatusIndicator";
@@ -80,6 +84,10 @@ export default function BrowserTab({ project, active }: Props) {
*/ */
const [poppedOut, setPoppedOut] = useState<boolean | null>(null); const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
const [onTop, setOnTop] = useState(false); 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 pushToast = useAppState((s) => s.pushToast);
const setContainerProgress = useAppState((s) => s.setContainerProgress); const setContainerProgress = useAppState((s) => s.setContainerProgress);
const progress = useAppState((s) => s.containerProgress[project.id]); 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 // Unreachable in practice, but a pane stuck at "not asked yet" would
// never show the view at all — so fail towards the tab. // never show the view at all — so fail towards the tab.
.catch(() => mounted.current && setPoppedOut(false)); .catch(() => mounted.current && setPoppedOut(false));
getBrowserViewMatchWindow(projectId)
.then((on) => mounted.current && setMatchWindow(on))
.catch(() => {});
getBrowserViewStatus(projectId) getBrowserViewStatus(projectId)
.then((s) => mounted.current && setStatus(s)) .then((s) => mounted.current && setStatus(s))
.catch(() => {}); .catch(() => {});
@@ -219,6 +230,55 @@ export default function BrowserTab({ project, active }: Props) {
[projectId, pushToast], [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. */ /** Run one install. Every path clears the progress line it started. */
const install = useCallback( const install = useCallback(
async (which: Exclude<SetupJob, null>) => { async (which: Exclude<SetupJob, null>) => {
@@ -283,7 +343,9 @@ export default function BrowserTab({ project, active }: Props) {
// apt package, so it never shows up in `browsers`, and a container that has // apt package, so it never shows up in `browsers`, and a container that has
// it is not missing a browser. // it is not missing a browser.
const needsBrowser = const needsBrowser =
probed !== null && probed.browsers.length === 0 && probed.chrome_channel === null; probed !== null &&
probed.chrome_channel === null &&
(probed.browsers.length === 0 || revisionSkew(probed));
const needsSetup = probed !== null && (!ready || needsBrowser); const needsSetup = probed !== null && (!ready || needsBrowser);
return ( return (
@@ -324,11 +386,25 @@ export default function BrowserTab({ project, active }: Props) {
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" /> <Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
</span> </span>
)} )}
{live && poppedOut === true && (
<span
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
title="Resize the page itself as the window is dragged, so the layout actually reflows. Applies to pages opened from here."
>
Match window
<Toggle checked={matchWindow} onChange={toggleMatchWindow} label="Match window" />
</span>
)}
{live && poppedOut === false && ( {live && poppedOut === false && (
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}> <Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
Reload Reload
</Button> </Button>
)} )}
{live && (
<Button size="md" onClick={() => setAskPage(true)}>
Open a page…
</Button>
)}
{live && poppedOut !== null && ( {live && poppedOut !== null && (
<Button size="md" onClick={poppedOut ? popIn : popOut}> <Button size="md" onClick={poppedOut ? popIn : popOut}>
{poppedOut ? "Put back in tab" : "Open in own window"} {poppedOut ? "Put back in tab" : "Open in own window"}
@@ -413,6 +489,14 @@ export default function BrowserTab({ project, active }: Props) {
)} )}
</div> </div>
)} )}
{askPage && (
<OpenPageDialog
busy={openingPage}
onOpen={openPage}
onClose={() => setAskPage(false)}
/>
)}
</div> </div>
); );
} }
@@ -422,6 +506,47 @@ function isUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null; return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
} }
/**
* Mirrors Rust `PlaywrightDetection::revision_skew`.
*
* Browsers are installed, but not the revision one of the two Playwright copies
* would launch — so the cache looks full and launches fail. A probe that didn't
* answer leaves the executable null, and "unknown" must not read as "broken".
*/
function revisionSkew(d: PlaywrightDetection | null): boolean {
if (!d || d.browsers.length === 0) return false;
// `!= null`, not `!== null`: a probe from a container that predates these
// fields omits them entirely, and `undefined` is "didn't answer" — which must
// never render as "your browsers are wrong".
const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists;
const scriptsBroken =
d.script_chromium_executable != null && !d.script_chromium_executable_exists;
return viewerBroken || scriptsBroken;
}
/**
* The skew sentence, naming both halves.
*
* "Install a browser" over a cache that visibly already holds one reads as
* nonsense, so the copy has to say which copy of Playwright wants what.
*/
function skewText(d: PlaywrightDetection | null): string {
if (!d) return "";
const scriptsBroken =
d.script_chromium_executable !== null && !d.script_chromium_executable_exists;
const [version, wanted] = scriptsBroken
? [d.script_playwright_version, d.script_chromium_executable]
: [d.playwright_version, d.chromium_executable];
return (
`This container has ${d.browsers.join(", ")}, but ` +
`${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` +
` — ${version ?? "?"} — launches ${wanted ?? "?"}, which isn’t there. ` +
(scriptsBroken
? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
: "Install Chromium below: it runs that build’s own installer, so it fetches exactly the revision that is missing.")
);
}
/** What the container is short of, as a list rather than as prose. */ /** What the container is short of, as a list rather than as prose. */
function missingParts(d: PlaywrightDetection | null): string[] { function missingParts(d: PlaywrightDetection | null): string[] {
if (!d) return []; if (!d) return [];
@@ -469,6 +594,10 @@ function Setup({
const browsers = detection?.browsers ?? []; const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null; const chrome = detection?.chrome_channel ?? null;
const noBrowser = browsers.length === 0 && chrome === null; const noBrowser = browsers.length === 0 && chrome === null;
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
// because the fix is the same button but the sentence must not be "install a
// browser" over a cache that visibly has one.
const skew = revisionSkew(detection) && chrome === null;
return ( return (
<div className="p-4 max-w-[46rem] space-y-4"> <div className="p-4 max-w-[46rem] space-y-4">
@@ -476,6 +605,8 @@ function Setup({
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]"> <h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
{!havePackages {!havePackages
? "This container can’t serve a browser view yet" ? "This container can’t serve a browser view yet"
: skew
? "The installed browser isn’t the one Playwright launches"
: noBrowser : noBrowser
? "Playwright is ready — but there’s no browser to drive yet" ? "Playwright is ready — but there’s no browser to drive yet"
: "This container is set up"} : "This container is set up"}
@@ -484,6 +615,8 @@ function Setup({
{message ?? {message ??
(missing.length > 0 (missing.length > 0
? `Missing: ${missing.join(", ")}.` ? `Missing: ${missing.join(", ")}.`
: skew
? skewText(detection)
: noBrowser : noBrowser
? "Playwright and the viewer are installed. Install a browser below so there is something to watch." ? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
: "Start the view from the button above once Claude has a browser open.")} : "Start the view from the button above once Claude has a browser open.")}
@@ -0,0 +1,148 @@
import { useState } from "react";
import Modal from "../../ui/Modal";
import Button from "../../ui/Button";
/**
* Viewport presets. These are the *page's* resolution, not the window's — the
* pane is a screencast, so a bigger window shows the same pixels drawn larger
* while this is what actually reflows the layout.
*/
const PRESETS: { label: string; width: number; height: number }[] = [
{ label: "1280 × 720", width: 1280, height: 720 },
{ label: "1920 × 1080", width: 1920, height: 1080 },
{ label: "1440 × 900", width: 1440, height: 900 },
{ label: "390 × 844 (phone)", width: 390, height: 844 },
];
interface Props {
/** Prefilled URL — an auth URL from the terminal, or the last one used. */
initialUrl?: string;
initialWidth?: number;
initialHeight?: number;
busy?: boolean;
onOpen: (url: string, width: number, height: number) => void;
onClose: () => void;
}
/**
* Ask for a URL and a viewport, then open it in the container's browser.
*
* Deliberately modal and short-lived — the convention for a task with one
* question and one button. The URL is not opened here; the caller runs the
* command so failures land in its toast.
*/
export default function OpenPageDialog({
initialUrl = "",
initialWidth = 1280,
initialHeight = 720,
busy = false,
onOpen,
onClose,
}: Props) {
const [url, setUrl] = useState(initialUrl);
const [width, setWidth] = useState(initialWidth);
const [height, setHeight] = useState(initialHeight);
const trimmed = url.trim();
// Mirrors the backend's allow-list, so the error arrives before the click
// rather than after a round trip.
const valid = /^https?:\/\/\S+$/i.test(trimmed);
return (
<Modal
title="Open a page in the container's browser"
onClose={onClose}
footer={
<>
<Button size="md" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={!valid || busy}
onClick={() => onOpen(trimmed, width, height)}
>
{busy ? "Opening…" : "Open page"}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
Launches a browser <em>inside</em> 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.
</p>
<label className="block">
<span className="text-xs text-[var(--text-secondary)]">URL</span>
<input
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && valid && !busy) onOpen(trimmed, width, height);
}}
placeholder="http://localhost:5173"
spellCheck={false}
className="mt-1 w-full px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] font-mono text-[var(--text-primary)]"
/>
{trimmed !== "" && !valid && (
<span className="mt-1 block text-xs text-[var(--error)]">
Only http:// and https:// URLs can be opened.
</span>
)}
</label>
<div>
<span className="text-xs text-[var(--text-secondary)]">Viewport</span>
<div className="mt-1 flex flex-wrap gap-1.5">
{PRESETS.map((p) => {
const active = p.width === width && p.height === height;
return (
<button
key={p.label}
type="button"
aria-pressed={active}
onClick={() => {
setWidth(p.width);
setHeight(p.height);
}}
className={`px-2 py-1 text-xs rounded-[var(--radius-control)] border transition-colors ${
active
? "border-[var(--accent)] bg-[var(--accent-muted)] text-[var(--accent)]"
: "border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{p.label}
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
aria-label="Viewport width"
value={width}
min={200}
onChange={(e) => 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)]"
/>
<span aria-hidden="true" className="text-xs text-[var(--text-secondary)]">×</span>
<input
type="number"
aria-label="Viewport height"
value={height}
min={200}
onChange={(e) => 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)]"
/>
<span className="text-xs text-[var(--text-secondary)]">CSS pixels</span>
</div>
</div>
</div>
</Modal>
);
}
+46 -1
View File
@@ -7,7 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; 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 { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector } from "../../lib/urlDetector";
import { import {
@@ -529,6 +533,46 @@ export default function TerminalView({ sessionId, active }: Props) {
openUrl(safe).catch((e) => console.error("Failed to open URL:", e)); openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, [urlPrompt]); }, [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 handleScrollToBottom = useCallback(() => {
const term = termRef.current; const term = termRef.current;
if (term) { if (term) {
@@ -606,6 +650,7 @@ export default function TerminalView({ sessionId, active }: Props) {
url={urlPrompt.url} url={urlPrompt.url}
label={urlPrompt.label} label={urlPrompt.label}
onOpen={handleOpenUrl} onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
onDismiss={() => setUrlPrompt(null)} onDismiss={() => setUrlPrompt(null)}
/> />
)} )}
+28
View File
@@ -6,6 +6,9 @@ interface Props {
/** Heading above the URL. Says why the toast appeared. */ /** Heading above the URL. Says why the toast appeared. */
label?: string; label?: string;
onOpen: () => void; 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; onDismiss: () => void;
} }
@@ -30,6 +33,7 @@ export default function UrlToast({
url, url,
label = "Long URL detected", label = "Long URL detected",
onOpen, onOpen,
onOpenInContainer,
onDismiss, onDismiss,
}: Props) { }: Props) {
const origin = urlOrigin(url); const origin = urlOrigin(url);
@@ -131,6 +135,30 @@ export default function UrlToast({
Open Open
</button> </button>
{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.
<button
onClick={onOpenInContainer}
title="Open in a browser inside the container, and watch it in the Browser tab"
style={{
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
In container
</button>
)}
<button <button
onClick={onDismiss} onClick={onDismiss}
style={{ style={{
+34 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types"; import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -222,6 +222,39 @@ export const closeBrowserViewPopout = (projectId: string) =>
*/ */
export const getBrowserViewPopoutState = (projectId: string) => export const getBrowserViewPopoutState = (projectId: string) =>
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId }); invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
/**
* Open a URL in a browser *inside* the container, published so the pane shows it.
*
* The same action serves an auth URL — the OAuth callback listener is in the
* container too, so the loop closes without the host — and a dev server on
* container loopback, which is how you watch a UI Claude is building. Only
* http/https; the backend rejects anything else.
*/
export const openPageInContainerBrowser = (
projectId: string,
url: string,
width: number,
height: number,
) => invoke<BrowserPageState>("open_page_in_container_browser", { projectId, url, width, height });
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
invoke<void>("set_container_page_viewport", { projectId, width, height });
export const getContainerPageState = (projectId: string) =>
invoke<BrowserPageState>("get_container_page_state", { projectId });
export const closeContainerPage = (projectId: string) =>
invoke<void>("close_container_page", { projectId });
/**
* 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.
*/
export const setBrowserViewMatchWindow = (projectId: string, enabled: boolean) =>
invoke<void>("set_browser_view_match_window", { projectId, enabled });
export const getBrowserViewMatchWindow = (projectId: string) =>
invoke<boolean>("get_browser_view_match_window", { projectId });
/** Pin the pop-out above other windows — the point of popping it out at all. */ /** Pin the pop-out above other windows — the point of popping it out at all. */
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) => export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop }); invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
+26
View File
@@ -469,6 +469,15 @@ export interface PlaywrightDetection {
/** Path to Google Chrome when the `chrome` channel — what `@playwright/mcp` /** Path to Google Chrome when the `chrome` channel — what `@playwright/mcp`
* asks for — is installed. It is an apt package, so it is never in `browsers`. */ * asks for — is installed. It is an apt package, so it is never in `browsers`. */
chrome_channel: string | null; chrome_channel: string | null;
/** The Chromium the *viewer's* Playwright would launch, and whether it exists. */
chromium_executable: string | null;
chromium_executable_exists: boolean;
/** What a script's `require("playwright")` resolves to — routinely a different
* copy, pinning a different browser revision. If its Chromium is missing,
* every script Claude writes fails while the pane still looks green. */
script_playwright_version: string | null;
script_chromium_executable: string | null;
script_chromium_executable_exists: boolean;
/** Module roots the probe searched, echoed back for the "not found" message. /** Module roots the probe searched, echoed back for the "not found" message.
* Includes the npx cache (`~/.npm/_npx/*​/node_modules`), which is where a * Includes the npx cache (`~/.npm/_npx/*​/node_modules`), which is where a
* Playwright installed through Claude Code's MCP setup actually lives. */ * Playwright installed through Claude Code's MCP setup actually lives. */
@@ -526,6 +535,23 @@ export interface BrowserViewPopoutState {
always_on_top: boolean; always_on_top: boolean;
} }
/** Mirrors Rust `page::Viewport` — CSS pixels, clamped backend-side. */
export interface BrowserPageViewport {
width: number;
height: number;
}
/**
* Mirrors Rust `page::PageState`: what the container-side helper reports about
* the page Triple-C opened. `ready: false` with no error means there is none.
*/
export interface BrowserPageState {
ready: boolean;
url: string | null;
viewport: BrowserPageViewport | null;
error: string | null;
}
/** /**
* Payload of the `browser-view-popout-changed` event: a `BrowserViewPopoutState` * Payload of the `browser-view-popout-changed` event: a `BrowserViewPopoutState`
* plus the project it belongs to. * plus the project it belongs to.