Secret Scan / scan (push) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / build-linux (pull_request) Successful in 8m39s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Review of this branch found that `update_project` restored `browser_view_enabled` from the store but took `auth_bridge_enabled` from the IPC payload, on a comment claiming the Config tab edits it through that save. The comment was wrong. `AuthBridgeRow` is the only writer, it calls `set_auth_bridge_enabled` out of band precisely so the switch works while a login is hanging, and it never writes the value back into frontend state -- so a payload's copy of that flag is always a stale snapshot. The consequence was not cosmetic: turn the bridge off, then close a renamed terminal tab, and `useTerminal` round-trips the stale `true` and the reconcile block restarts a bridge whose own UI warns that a bridged port is unauthenticated and reachable by any local process. Defaulting the flag to true earlier in this branch made it worse, since the stale value is now true for every pre-existing project. Both flags are now restored from the store by `restore_store_owned_fields`, and the reconcile block is gone rather than corrected: with the value always restored it could only re-assert what was already true, and every writer already owns its own side effect -- the setter starts and stops synchronously, container start arms the bridge, launch reconcile re-arms it, and the poller re-reads the flag each tick and self-terminates. Re-adding a start path to the one function that no longer owns the flag is what caused this. Turning the browser view off also stopped tearing the session down when the project record had vanished, because the persist used `?` and returned early -- the supervisor's own `store.get()` check exists because records do vanish mid-session. Teardown is now unconditional and the write error still surfaces afterwards, since the stored flag saying "enabled" means the view returns on next launch and that is worth reporting. Finally, the opener no longer falls through to `gio` on any non-zero exit. xdg-open's 1, 2 and 3 assert no handler ran; 4 also covers a handler that was launched and then failed, which would have opened the link twice -- two authorize requests for one click in an OAuth flow. Reasoned from documented exit codes rather than an observed double-open, and the cost is stated: a genuine code-4 failure no longer reaches gio. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
457 lines
17 KiB
Rust
457 lines
17 KiB
Rust
//! IPC surface for the browser view pane. The mechanism lives in
|
|
//! [`crate::browser_view`]; this file only translates between it and the
|
|
//! frontend.
|
|
|
|
use tauri::{AppHandle, State};
|
|
|
|
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
|
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
|
use crate::AppState;
|
|
|
|
/// Turn the pane on or off for a project.
|
|
///
|
|
/// Enabling probes the container and brings the viewer up when it can; a
|
|
/// container that isn't running, or one without Playwright, comes back as a
|
|
/// non-`Running` status carrying an explanation rather than an error, so the
|
|
/// pane always has something specific to say. This is host-side only — no
|
|
/// container recreation is involved either way.
|
|
///
|
|
/// Either way the choice is persisted, so it survives an app restart. This is
|
|
/// the only caller allowed to write `false`: every other path to
|
|
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
|
|
/// is a teardown rather than the user changing their mind. Enabling persists
|
|
/// inside `start`, which is the single funnel for it.
|
|
#[tauri::command]
|
|
pub async fn set_browser_view_enabled(
|
|
project_id: String,
|
|
enabled: bool,
|
|
app_handle: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<BrowserViewStatus, String> {
|
|
if !enabled {
|
|
// Persist first, then tear down: the supervisor's own teardown emit
|
|
// reads this flag back out of the store, and reading it mid-stop would
|
|
// announce a view that is going away as still enabled.
|
|
//
|
|
// But the write's outcome is a *value*, not a branch. A `?` here meant
|
|
// that a store with no such project record returned early and
|
|
// `manager().stop()` never ran, leaving the supervisor, the proxy and
|
|
// the host port up for a project that, as far as the user is concerned,
|
|
// just had its view switched off. That state is not hypothetical while
|
|
// a session is live — the supervisor's own `store.get()` check in
|
|
// [`crate::browser_view`] exists because a record can go away
|
|
// underneath it — and before the flag was persisted at all, turning the
|
|
// view off always tore the session down.
|
|
let persisted = state
|
|
.projects_store
|
|
.set_browser_view_enabled(&project_id, false);
|
|
// Awaits the supervisor, so the host port is released before we return.
|
|
//
|
|
// A failed write is still reported rather than logged and swallowed.
|
|
// The resources are gone either way by this point, so surfacing it
|
|
// costs nothing that matters, and the failure it describes is one the
|
|
// user needs: the stored flag still says *enabled*, so the view comes
|
|
// back by itself on the next launch. Returning `Ok` would be a claim
|
|
// about persistence that isn't true.
|
|
tear_down_then_report(persisted, manager().stop(&project_id)).await?;
|
|
return Ok(manager().status(&project_id, false).await);
|
|
}
|
|
|
|
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
|
|
|
manager()
|
|
.start(
|
|
project_id,
|
|
container_id,
|
|
app_handle,
|
|
state.projects_store.clone(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Await `teardown`, then report `persisted`.
|
|
///
|
|
/// Trivial on purpose, and split out for one reason: it is the whole rule the
|
|
/// disable path of [`set_browser_view_enabled`] has to obey — the teardown is
|
|
/// unconditional, and a failed persist surfaces only after it has run — and as
|
|
/// a free function that rule can be tested without a live `AppState`.
|
|
async fn tear_down_then_report(
|
|
persisted: Result<(), String>,
|
|
teardown: impl std::future::Future<Output = ()>,
|
|
) -> Result<(), String> {
|
|
teardown.await;
|
|
persisted
|
|
}
|
|
|
|
/// Current status. Cheap: the session map in this process plus the stored flag,
|
|
/// never the container.
|
|
///
|
|
/// The two are independent on purpose — this is what the pane reads on mount,
|
|
/// and after an app restart the honest answer is "enabled, nothing running".
|
|
#[tauri::command]
|
|
pub async fn get_browser_view_status(
|
|
project_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<BrowserViewStatus, String> {
|
|
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
|
|
}
|
|
|
|
/// Probe the container for Playwright without starting anything.
|
|
///
|
|
/// Lets the pane say "install this" before the user asks for a view, and lets
|
|
/// them re-check after installing without toggling the feature. Read-only: it
|
|
/// runs one `node -e` and changes nothing.
|
|
#[tauri::command]
|
|
pub async fn check_browser_view_support(
|
|
project_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
|
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
|
crate::browser_view::detect::detect(&container_id).await
|
|
}
|
|
|
|
/// Install `playwright` and `@playwright/cli` into the container.
|
|
///
|
|
/// **This mutates the container**, so it is a command of its own and is only
|
|
/// ever reached by the user pressing the button — nothing here runs on tab
|
|
/// open. Progress streams on `container-progress`; the outcome carries a fresh
|
|
/// probe so the pane updates itself.
|
|
///
|
|
/// Browsers are *not* fetched here. They are hundreds of megabytes and get
|
|
/// their own action, with the size stated before the click.
|
|
#[tauri::command]
|
|
pub async fn install_browser_view_support(
|
|
project_id: String,
|
|
app_handle: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<BrowserSetupOutcome, String> {
|
|
let container_id = running_container(&state, &project_id, "installing Playwright").await?;
|
|
install::install_packages(&app_handle, &project_id, &container_id).await
|
|
}
|
|
|
|
/// Install a browser — `chromium` (Playwright's own build, for scripts that
|
|
/// call `chromium.launch()`) or `chrome` (the Google Chrome channel that
|
|
/// `@playwright/mcp` asks for) — along with the system libraries it needs, and
|
|
/// verify that it actually starts.
|
|
///
|
|
/// Also a mutation, also user-initiated only.
|
|
#[tauri::command]
|
|
pub async fn install_browser_view_browser(
|
|
project_id: String,
|
|
browser: String,
|
|
app_handle: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<BrowserSetupOutcome, String> {
|
|
let target = install::BrowserTarget::parse(&browser)?;
|
|
let container_id = running_container(&state, &project_id, "installing a browser").await?;
|
|
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
|
}
|
|
|
|
/// Detach the view into a window of its own, or raise the one already open.
|
|
///
|
|
/// Host-side and window-only: the viewer keeps running exactly as it was, and
|
|
/// this touches neither the container nor the proxy. Requires a *live* view,
|
|
/// because a window with nothing behind it is not worth opening — the pane
|
|
/// only offers the button in that state, and this enforces it.
|
|
#[tauri::command]
|
|
pub async fn open_browser_view_popout(
|
|
project_id: String,
|
|
always_on_top: bool,
|
|
app_handle: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
let status = manager()
|
|
.status(&project_id, enabled_for(&state, &project_id))
|
|
.await;
|
|
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
|
return Err(
|
|
"The browser view isn't running. Start it before opening it in its own window."
|
|
.to_string(),
|
|
);
|
|
};
|
|
|
|
let name = state
|
|
.projects_store
|
|
.get(&project_id)
|
|
.map(|p| p.name)
|
|
.unwrap_or_else(|| "Triple-C".to_string());
|
|
|
|
popout::open(&app_handle, &project_id, &name, url, always_on_top)
|
|
}
|
|
|
|
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
|
///
|
|
/// Propagates a failed close rather than reporting success: the pane restores
|
|
/// its iframe on success, and doing that with the window still up puts two
|
|
/// viewers on one browser.
|
|
#[tauri::command]
|
|
pub async fn close_browser_view_popout(
|
|
project_id: String,
|
|
app_handle: AppHandle,
|
|
) -> Result<(), String> {
|
|
popout::close(&app_handle, &project_id)
|
|
}
|
|
|
|
/// Whether the pop-out is open, and whether it is pinned on top.
|
|
///
|
|
/// Read on every pane mount: the window outlives the pane — which is unmounted
|
|
/// whenever another Project Home sub-tab is selected — so neither fact can be
|
|
/// carried in component state.
|
|
#[tauri::command]
|
|
pub async fn get_browser_view_popout_state(
|
|
project_id: String,
|
|
app_handle: AppHandle,
|
|
) -> Result<popout::PopoutState, String> {
|
|
Ok(popout::state(&app_handle, &project_id))
|
|
}
|
|
|
|
/// Pin the pop-out above other windows, so it can be watched while working in
|
|
/// the main one.
|
|
#[tauri::command]
|
|
pub async fn set_browser_view_popout_always_on_top(
|
|
project_id: String,
|
|
on_top: bool,
|
|
app_handle: AppHandle,
|
|
) -> Result<(), String> {
|
|
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,
|
|
show_window: bool,
|
|
app_handle: AppHandle,
|
|
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?;
|
|
crate::commands::project_commands::emit_progress(
|
|
&app_handle,
|
|
&project_id,
|
|
"Checking the container for Playwright…",
|
|
);
|
|
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
|
let opened = page::open(
|
|
&app_handle,
|
|
&project_id,
|
|
&container_id,
|
|
&detection,
|
|
trimmed,
|
|
page::Viewport::sane(width, height),
|
|
)
|
|
.await?;
|
|
|
|
// A page nobody can see is not an opened page. Opening one used to leave
|
|
// the user to go and press Start in the Browser tab themselves — and from
|
|
// the terminal's URL prompt, with no indication that was even needed.
|
|
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
|
let status = manager()
|
|
.status(&project_id, enabled_for(&state, &project_id))
|
|
.await;
|
|
if status.state != BrowserViewState::Running {
|
|
crate::commands::project_commands::emit_progress(
|
|
&app_handle,
|
|
&project_id,
|
|
"Starting the viewer…",
|
|
);
|
|
manager()
|
|
.start(
|
|
project_id.clone(),
|
|
container_id,
|
|
app_handle.clone(),
|
|
state.projects_store.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// From the terminal there is no pane on screen to fill, so the page needs a
|
|
// window of its own or it lands somewhere the user isn't looking.
|
|
if show_window {
|
|
let status = manager()
|
|
.status(&project_id, enabled_for(&state, &project_id))
|
|
.await;
|
|
if let Some(url) = status.url.as_deref() {
|
|
let name = state
|
|
.projects_store
|
|
.get(&project_id)
|
|
.map(|p| p.name)
|
|
.unwrap_or_else(|| "Triple-C".to_string());
|
|
popout::open(&app_handle, &project_id, &name, url, false)?;
|
|
}
|
|
}
|
|
|
|
crate::commands::project_commands::emit_progress(&app_handle, &project_id, "");
|
|
Ok(opened)
|
|
}
|
|
|
|
/// 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 stored browser-view opt-in.
|
|
///
|
|
/// The manager holds no copy of this — see
|
|
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
|
|
/// status call reads it here, the way `get_auth_bridge_status` does. A project
|
|
/// that has gone away reads as off, which is the only answer that can be given
|
|
/// about a record that no longer exists.
|
|
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
|
|
state
|
|
.projects_store
|
|
.get(project_id)
|
|
.is_some_and(|p| p.browser_view_enabled)
|
|
}
|
|
|
|
/// 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
|
|
/// to be able to fail somewhere further in with a Docker error instead. The
|
|
/// `action` is folded into the message so "start the container first" arrives
|
|
/// attached to what the user was trying to do.
|
|
async fn running_container(
|
|
state: &State<'_, AppState>,
|
|
project_id: &str,
|
|
action: &str,
|
|
) -> Result<String, String> {
|
|
let project = state
|
|
.projects_store
|
|
.get(project_id)
|
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
|
|
|
let Some(container_id) = project.container_id.clone() else {
|
|
return Err(format!(
|
|
"This project has no container yet. Start it before {}.",
|
|
action
|
|
));
|
|
};
|
|
if !crate::docker::container::is_container_running(&container_id)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(format!(
|
|
"The container for “{}” isn't running. Start it before {}.",
|
|
project.name, action
|
|
));
|
|
}
|
|
Ok(container_id)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
/// The regression: turning the view off must not leave the supervisor, the
|
|
/// proxy and the host port running just because the project record could
|
|
/// not be written — which is exactly what a missing record did.
|
|
#[tokio::test]
|
|
async fn a_failed_persist_does_not_skip_the_teardown() {
|
|
let torn_down = AtomicBool::new(false);
|
|
let result = tear_down_then_report(Err("Project x not found".to_string()), async {
|
|
torn_down.store(true, Ordering::SeqCst);
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
torn_down.load(Ordering::SeqCst),
|
|
"the session must be torn down even when the store write failed"
|
|
);
|
|
assert_eq!(
|
|
result.err().as_deref(),
|
|
Some("Project x not found"),
|
|
"and the write failure must still reach the caller, not be swallowed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_successful_persist_reports_success_after_the_teardown() {
|
|
let torn_down = AtomicBool::new(false);
|
|
let result = tear_down_then_report(Ok(()), async {
|
|
torn_down.store(true, Ordering::SeqCst);
|
|
})
|
|
.await;
|
|
|
|
assert!(torn_down.load(Ordering::SeqCst));
|
|
assert!(result.is_ok());
|
|
}
|
|
}
|