Reorder tabs by dragging, and pop the browser view into its own window #19
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
@@ -130,6 +138,9 @@ beforeEach(() => {
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
setBrowserViewMatchWindow.mockResolvedValue(undefined);
|
||||
getBrowserViewMatchWindow.mockResolvedValue(false);
|
||||
openPageInContainerBrowser.mockResolvedValue({ error: null });
|
||||
});
|
||||
|
||||
const LIVE: BrowserViewStatus = {
|
||||
@@ -494,6 +505,58 @@ describe("BrowserTab", () => {
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a page in the container’s browser at the chosen viewport", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "http://localhost:5173" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
|
||||
});
|
||||
|
||||
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
"http://localhost:5173",
|
||||
1920,
|
||||
1080,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||
target: { value: "file:///etc/passwd" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
|
||||
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
|
||||
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers match-window only once the view is in its own window", async () => {
|
||||
await renderLive();
|
||||
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
|
||||
});
|
||||
|
||||
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
|
||||
});
|
||||
|
||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||
await renderLive();
|
||||
openBrowserViewPopout.mockRejectedValue("no display");
|
||||
|
||||
@@ -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>) => {
|
||||
@@ -326,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"}
|
||||
@@ -415,6 +489,14 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askPage && (
|
||||
<OpenPageDialog
|
||||
busy={openingPage}
|
||||
onOpen={openPage}
|
||||
onClose={() => setAskPage(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -535,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