Compare commits
2
Commits
1207a21aae
...
f68d9c5788
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68d9c5788 | ||
|
|
bd72781482 |
@@ -126,6 +126,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
||||
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||
the browser.
|
||||
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
|
||||
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
|
||||
keep the handle. Serves auth (the OAuth callback listener is *in* the container, so a
|
||||
container-side browser closes the loop with no host round trip and no auth bridge) and dev
|
||||
servers on container loopback. **Verified: a second client cannot join a bound browser** —
|
||||
`chromium.connect()` against the published endpoint times out in every URL form, because that
|
||||
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
|
||||
is the only process that can drive, which is why the helper is resident and why live resize
|
||||
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
|
||||
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
|
||||
no second listener — and a re-open with a helper already up *navigates* rather than
|
||||
relaunching, so a session signed in on one page survives to the next.
|
||||
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger
|
||||
window is the same pixels drawn larger. `page.setViewportSize()` is what reflows (measured
|
||||
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
|
||||
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
||||
continuously and each one costs a container exec.
|
||||
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
||||
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||
container stopped, process exited.
|
||||
|
||||
+25
-1
@@ -280,11 +280,35 @@ Press **Start browser view** and the pane fills with Playwright's own dashboard,
|
||||
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
||||
exposed off the machine.
|
||||
|
||||
#### Opening a page yourself
|
||||
|
||||
**Open a page…** launches a browser inside the container at a URL and viewport you choose, and
|
||||
publishes it to this pane. Two uses:
|
||||
|
||||
- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so
|
||||
a container-side browser completes the login without anything crossing to your host browser.
|
||||
When a long URL appears in a terminal, the prompt that offers to open it on your host now also
|
||||
offers **In container**, which does the same thing in one click.
|
||||
- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping
|
||||
and nothing exposed to your network — which is how you watch a UI Claude is building, and click
|
||||
around it yourself.
|
||||
|
||||
The **viewport** is the page's own resolution, and it is not the same thing as the window size.
|
||||
The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing
|
||||
the viewport is what makes the layout actually reflow. Pick a preset or type a size.
|
||||
|
||||
Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can
|
||||
be *watched* but not resized — a published browser admits only the client that launched it. Set
|
||||
its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables
|
||||
instead.
|
||||
|
||||
#### Watching it while you work
|
||||
|
||||
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
|
||||
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
||||
terminal. This is a window change only: the browser and the view keep running throughout, so
|
||||
terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so
|
||||
the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**,
|
||||
for the reason above. This is a window change only: the browser and the view keep running throughout, so
|
||||
popping out and back costs nothing and interrupts nothing.
|
||||
|
||||
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||
use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
@@ -164,6 +164,106 @@ pub async fn set_browser_view_popout_always_on_top(
|
||||
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
||||
}
|
||||
|
||||
/// Open a URL in a browser *inside* the container, published so the pane shows
|
||||
/// it.
|
||||
///
|
||||
/// Two uses, one action: an auth URL — where the OAuth callback listener is in
|
||||
/// the container too, so the loop closes without the host being involved at all
|
||||
/// — and a dev server on container loopback, which is how you watch a UI Claude
|
||||
/// is building.
|
||||
///
|
||||
/// The scheme allow-list mirrors the URL relay's: `http`/`https` only, so this
|
||||
/// can never be talked into opening `file:` on the container's filesystem.
|
||||
#[tauri::command]
|
||||
pub async fn open_page_in_container_browser(
|
||||
project_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<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.
|
||||
///
|
||||
/// Every command here needs a *running* container, and every one of them used
|
||||
|
||||
@@ -93,6 +93,32 @@ pub struct PlaywrightDetection {
|
||||
/// user's own scripts and not for the MCP plugin.
|
||||
#[serde(default)]
|
||||
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.
|
||||
#[serde(default)]
|
||||
pub searched: Vec<String>,
|
||||
@@ -155,13 +181,82 @@ impl PlaywrightDetection {
|
||||
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 —
|
||||
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||
/// still runs, it just has nothing to show until a browser is bound.
|
||||
pub fn needs_browser(&self) -> bool {
|
||||
self.playwright_version.is_some()
|
||||
&& self.browsers.is_empty()
|
||||
&& 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
|
||||
@@ -277,6 +372,30 @@ const PROBE: &str = concat!(
|
||||
// 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#"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
|
||||
// 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"]){"#,
|
||||
@@ -467,6 +586,66 @@ mod tests {
|
||||
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]
|
||||
fn a_missing_viewer_package_is_reported_separately() {
|
||||
let d = parse_probe_output(&payload(
|
||||
|
||||
@@ -73,14 +73,33 @@ use crate::docker::exec::{
|
||||
|
||||
use super::detect::{self, PlaywrightDetection};
|
||||
|
||||
/// The two packages the pane genuinely needs, pinned to `@latest` because
|
||||
/// `browser.bind()` is recent and the viewer tracks it.
|
||||
/// The viewer package — installed **first**, and it decides the version of
|
||||
/// `playwright` installed after it.
|
||||
///
|
||||
/// This is the *minimum* set. A user who followed the old guidance ended up
|
||||
/// with a global install as well as these; only these are required. Note what
|
||||
/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not
|
||||
/// this pane's, and it contributes nothing to serving a viewer.
|
||||
pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@latest"];
|
||||
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
|
||||
/// configuration to make, and it contributes nothing to serving a viewer.
|
||||
///
|
||||
/// **Order matters here, and `playwright` is deliberately not `@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
|
||||
/// the module docs.
|
||||
@@ -213,49 +232,33 @@ pub async fn install_packages(
|
||||
emit_progress(
|
||||
app,
|
||||
project_id,
|
||||
&format!(
|
||||
"Installing playwright and @playwright/cli into {}/node_modules…",
|
||||
INSTALL_DIR
|
||||
),
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
// `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 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?;
|
||||
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
|
||||
if step.exit_code != 0 {
|
||||
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.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…");
|
||||
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
|
||||
// never show them anything.
|
||||
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,
|
||||
"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
|
||||
/// one actually starts.
|
||||
///
|
||||
@@ -865,10 +954,20 @@ mod tests {
|
||||
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
||||
// The viewer package is not optional, and `@playwright/mcp` is not a
|
||||
// member: it can bind sessions, it can never serve the UI.
|
||||
assert!(PACKAGES.iter().any(|p| p.starts_with("playwright@")));
|
||||
assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@")));
|
||||
assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp")));
|
||||
assert_eq!(PACKAGES.len(), 2);
|
||||
assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
|
||||
assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
|
||||
assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
pub mod install;
|
||||
pub mod page;
|
||||
pub mod popout;
|
||||
pub mod proxy;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@
|
||||
//! dead viewer is worse than no window. The reverse is not true; closing the
|
||||
//! window leaves the view running, and the pane takes it back into the tab.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
@@ -106,11 +110,17 @@ pub fn open(
|
||||
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
||||
|
||||
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||
// to take the view back into the tab.
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Destroyed) {
|
||||
// to take the view back into the tab. `Resized` drives match-window mode —
|
||||
// see `set_match_window`.
|
||||
window.on_window_event(move |event| match event {
|
||||
WindowEvent::Destroyed => {
|
||||
set_match_window(&project_id_owned, false);
|
||||
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
on_resized(&app_for_event, &project_id_owned, size.width, size.height);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
log::info!("Browser view: popped out for project {}", project_id);
|
||||
@@ -170,6 +180,99 @@ pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Match-window mode
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Projects whose pop-out is driving the page's viewport, and the generation of
|
||||
/// the latest resize for each — the debounce is "did anything else arrive while
|
||||
/// I slept?", which needs no timer to cancel.
|
||||
static MATCH_WINDOW: OnceLock<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) {
|
||||
let _ = app.emit(
|
||||
POPOUT_EVENT,
|
||||
@@ -198,4 +301,38 @@ mod tests {
|
||||
fn distinct_projects_get_distinct_windows() {
|
||||
assert_ne!(window_label("alpha"), window_label("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_window_is_off_until_asked_for_and_is_per_project() {
|
||||
assert!(!match_window("mw-a"));
|
||||
set_match_window("mw-a", true);
|
||||
assert!(match_window("mw-a"));
|
||||
// Another project's window must not start driving its page too.
|
||||
assert!(!match_window("mw-b"));
|
||||
set_match_window("mw-a", false);
|
||||
assert!(!match_window("mw-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resize_supersedes_the_one_before_it() {
|
||||
// The debounce is a generation counter, not a cancellable timer: only
|
||||
// the newest resize of a drag survives to touch the container.
|
||||
set_match_window("mw-gen", true);
|
||||
let read = || {
|
||||
match_window_map()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("mw-gen")
|
||||
.map(|(_, g)| *g)
|
||||
.unwrap()
|
||||
};
|
||||
let before = read();
|
||||
{
|
||||
let mut map = match_window_map().lock().unwrap();
|
||||
let entry = map.get_mut("mw-gen").unwrap();
|
||||
entry.1 += 1;
|
||||
}
|
||||
assert!(read() > before);
|
||||
set_match_window("mw-gen", false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,12 @@ pub fn run() {
|
||||
browser_view::commands::close_browser_view_popout,
|
||||
browser_view::commands::get_browser_view_popout_state,
|
||||
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||
browser_view::commands::open_page_in_container_browser,
|
||||
browser_view::commands::set_container_page_viewport,
|
||||
browser_view::commands::get_container_page_state,
|
||||
browser_view::commands::close_container_page,
|
||||
browser_view::commands::set_browser_view_match_window,
|
||||
browser_view::commands::get_browser_view_match_window,
|
||||
// Shared Claude Code auth token
|
||||
commands::auth_token_commands::acquire_claude_token,
|
||||
commands::auth_token_commands::submit_claude_token_code,
|
||||
|
||||
@@ -18,6 +18,10 @@ const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
||||
const getBrowserViewPopoutState =
|
||||
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
||||
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 setContainerProgress = vi.fn();
|
||||
|
||||
@@ -32,6 +36,10 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
|
||||
openPageInContainerBrowser(id, url, w, h),
|
||||
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
|
||||
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -69,6 +77,11 @@ const NOTHING: PlaywrightDetection = {
|
||||
cli_entry: null,
|
||||
browsers: [],
|
||||
chrome_channel: null,
|
||||
chromium_executable: null,
|
||||
chromium_executable_exists: false,
|
||||
script_playwright_version: null,
|
||||
script_chromium_executable: null,
|
||||
script_chromium_executable_exists: false,
|
||||
searched: [
|
||||
"/workspace",
|
||||
"/usr/lib/node_modules",
|
||||
@@ -125,6 +138,9 @@ beforeEach(() => {
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
setBrowserViewMatchWindow.mockResolvedValue(undefined);
|
||||
getBrowserViewMatchWindow.mockResolvedValue(false);
|
||||
openPageInContainerBrowser.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
const LIVE: BrowserViewStatus = {
|
||||
@@ -362,6 +378,41 @@ describe("BrowserTab", () => {
|
||||
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 () => {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
render(<BrowserTab project={project} active />);
|
||||
@@ -454,6 +505,58 @@ describe("BrowserTab", () => {
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a page in the container’s browser at the chosen viewport", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "http://localhost:5173" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
|
||||
});
|
||||
|
||||
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
"http://localhost:5173",
|
||||
1920,
|
||||
1080,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "file:///etc/passwd" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
|
||||
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
|
||||
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers match-window only once the view is in its own window", async () => {
|
||||
await renderLive();
|
||||
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
|
||||
});
|
||||
|
||||
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
|
||||
});
|
||||
|
||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||
await renderLive();
|
||||
openBrowserViewPopout.mockRejectedValue("no display");
|
||||
|
||||
@@ -15,12 +15,16 @@ import {
|
||||
getBrowserViewStatus,
|
||||
installBrowserViewBrowser,
|
||||
installBrowserViewSupport,
|
||||
getBrowserViewMatchWindow,
|
||||
getBrowserViewPopoutState,
|
||||
openBrowserViewPopout,
|
||||
openPageInContainerBrowser,
|
||||
setBrowserViewEnabled,
|
||||
setBrowserViewMatchWindow,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import OpenPageDialog from "./OpenPageDialog";
|
||||
import AccordionSection from "../../ui/AccordionSection";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
@@ -80,6 +84,10 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
*/
|
||||
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
|
||||
const [onTop, setOnTop] = useState(false);
|
||||
/** The "open a page" dialog, and the request it is running. */
|
||||
const [matchWindow, setMatchWindow] = useState(false);
|
||||
const [askPage, setAskPage] = useState(false);
|
||||
const [openingPage, setOpeningPage] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||
const progress = useAppState((s) => s.containerProgress[project.id]);
|
||||
@@ -137,6 +145,9 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
// Unreachable in practice, but a pane stuck at "not asked yet" would
|
||||
// never show the view at all — so fail towards the tab.
|
||||
.catch(() => mounted.current && setPoppedOut(false));
|
||||
getBrowserViewMatchWindow(projectId)
|
||||
.then((on) => mounted.current && setMatchWindow(on))
|
||||
.catch(() => {});
|
||||
getBrowserViewStatus(projectId)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
@@ -219,6 +230,55 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/**
|
||||
* Open a URL in a browser inside the container.
|
||||
*
|
||||
* The pane only ever *watched* browsers something else published; this is the
|
||||
* one action that opens one. It also means the page can be resized later —
|
||||
* whoever launches a bound browser is the only process that can drive it.
|
||||
*/
|
||||
const openPage = useCallback(
|
||||
async (url: string, width: number, height: number) => {
|
||||
setOpeningPage(true);
|
||||
try {
|
||||
const result = await openPageInContainerBrowser(projectId, url, width, height);
|
||||
if (!mounted.current) return;
|
||||
setAskPage(false);
|
||||
if (result.error) {
|
||||
pushToast({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||
} else {
|
||||
pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` });
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open the page in the container’s browser",
|
||||
detail: String(e),
|
||||
});
|
||||
} finally {
|
||||
if (mounted.current) setOpeningPage(false);
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
const toggleMatchWindow = useCallback(
|
||||
async (next: boolean) => {
|
||||
setMatchWindow(next);
|
||||
try {
|
||||
await setBrowserViewMatchWindow(projectId, next);
|
||||
} catch (e) {
|
||||
if (mounted.current) setMatchWindow(!next);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not match the page to the window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/** Run one install. Every path clears the progress line it started. */
|
||||
const install = useCallback(
|
||||
async (which: Exclude<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
|
||||
// it is not missing a browser.
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -324,11 +386,25 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
||||
</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 && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
{live && (
|
||||
<Button size="md" onClick={() => setAskPage(true)}>
|
||||
Open a page…
|
||||
</Button>
|
||||
)}
|
||||
{live && poppedOut !== null && (
|
||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||
@@ -413,6 +489,14 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askPage && (
|
||||
<OpenPageDialog
|
||||
busy={openingPage}
|
||||
onOpen={openPage}
|
||||
onClose={() => setAskPage(false)}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
function missingParts(d: PlaywrightDetection | null): string[] {
|
||||
if (!d) return [];
|
||||
@@ -469,6 +594,10 @@ function Setup({
|
||||
const browsers = detection?.browsers ?? [];
|
||||
const chrome = detection?.chrome_channel ?? 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 (
|
||||
<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)]">
|
||||
{!havePackages
|
||||
? "This container can’t serve a browser view yet"
|
||||
: skew
|
||||
? "The installed browser isn’t the one Playwright launches"
|
||||
: noBrowser
|
||||
? "Playwright is ready — but there’s no browser to drive yet"
|
||||
: "This container is set up"}
|
||||
@@ -484,6 +615,8 @@ function Setup({
|
||||
{message ??
|
||||
(missing.length > 0
|
||||
? `Missing: ${missing.join(", ")}.`
|
||||
: skew
|
||||
? skewText(detection)
|
||||
: noBrowser
|
||||
? "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.")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||
import {
|
||||
awsSsoRefresh,
|
||||
openPageInContainerBrowser,
|
||||
uploadHostFileToTerminal,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import {
|
||||
@@ -529,6 +533,46 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||||
}, [urlPrompt]);
|
||||
|
||||
/**
|
||||
* Open the prompted URL in the container's own browser instead of the host's.
|
||||
*
|
||||
* For a sign-in this is the shorter path: the callback listener the tool is
|
||||
* waiting on is inside the container, so a container-side browser closes the
|
||||
* loop with nothing crossing to the host. The page is published to the
|
||||
* project's Browser tab, which is where the user completes it by hand.
|
||||
*/
|
||||
const handleOpenUrlInContainer = useCallback(() => {
|
||||
if (!urlPrompt) return;
|
||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||
setUrlPrompt(null);
|
||||
if (!safe) {
|
||||
console.warn("Refusing to open a URL that failed validation");
|
||||
return;
|
||||
}
|
||||
if (!projectId) return;
|
||||
// A sign-in page is the one case where the *window* size matters least and
|
||||
// the layout matters most, so it gets the ordinary desktop viewport.
|
||||
openPageInContainerBrowser(projectId, safe, 1280, 720)
|
||||
.then((result) => {
|
||||
const push = useAppState.getState().pushToast;
|
||||
if (result.error) {
|
||||
push({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||
} else {
|
||||
push({
|
||||
kind: "success",
|
||||
message: "Opened in the container’s browser — see the project’s Browser tab",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) =>
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open it in the container’s browser",
|
||||
detail: String(e),
|
||||
}),
|
||||
);
|
||||
}, [urlPrompt, projectId]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
@@ -606,6 +650,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
url={urlPrompt.url}
|
||||
label={urlPrompt.label}
|
||||
onOpen={handleOpenUrl}
|
||||
onOpenInContainer={handleOpenUrlInContainer}
|
||||
onDismiss={() => setUrlPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,9 @@ interface Props {
|
||||
/** Heading above the URL. Says why the toast appeared. */
|
||||
label?: string;
|
||||
onOpen: () => void;
|
||||
/** Open it in the container's own browser instead of the host's. Omitted when
|
||||
* the project has no browser to open it in. */
|
||||
onOpenInContainer?: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ export default function UrlToast({
|
||||
url,
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onOpenInContainer,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const origin = urlOrigin(url);
|
||||
@@ -131,6 +135,30 @@ export default function UrlToast({
|
||||
Open
|
||||
</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
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -222,6 +222,39 @@ export const closeBrowserViewPopout = (projectId: string) =>
|
||||
*/
|
||||
export const getBrowserViewPopoutState = (projectId: string) =>
|
||||
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. */
|
||||
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
||||
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
|
||||
|
||||
@@ -469,6 +469,15 @@ export interface PlaywrightDetection {
|
||||
/** 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`. */
|
||||
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.
|
||||
* Includes the npx cache (`~/.npm/_npx/*/node_modules`), which is where a
|
||||
* Playwright installed through Claude Code's MCP setup actually lives. */
|
||||
@@ -526,6 +535,23 @@ export interface BrowserViewPopoutState {
|
||||
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`
|
||||
* plus the project it belongs to.
|
||||
|
||||
Reference in New Issue
Block a user