Files
Codeman/packages/desktop/src/main.rs
T
Codeman maintainer 01d3379b74 fix(desktop): recover a crashed page, name every dialog's frame, and four smaller review fixes
The findings of the 2026-11-05 review of this branch, each with a unit test
where the code can be tested without a window:

- A window whose web content process ended (a WebKit crash, or macOS taking
  the process back under memory pressure) stayed blank: wry answers WebKit's
  notice itself, which turns WebKit's own reload off. The window now loads its
  page again. A second end within 30 seconds sends the main window back to the
  connect page and closes a pop-out, so it cannot loop (policy::recovery).
- A JavaScript dialog from a frame nothing names (about:srcdoc, a data: or
  blob: URL under an opaque origin) showed the page's text as its bold first
  line. The first line is now always the app's own, the "embedded" flag is set
  when the origin names no host (it was the wrong way round), and every
  sub-frame counts as embedded, a trusted web tab on Codeman's origin included.
- The connect page lost the text of an error that needs the login fields: the
  tab switch cleared the status it had just set.
- Start for http://localhost:PORT missed the LaunchAgent (the compare was
  exact against 127.0.0.1) and ran a second, unsupervised server on its port.
- The path picker's preview Open link (api/filesystem/preview) did nothing: it
  was the one page Codeman opens in a window that the allowlist lacked.
- The port of a running install was lost whenever the Terminal script that
  wraps the installer came first in ps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:20:01 +01:00

1911 lines
78 KiB
Rust

//! Codeman Desktop: a native window around a Codeman server.
//!
//! Codeman is a web app, so the app is a thin shell: the OS web view (WKWebView
//! on macOS) shows the server's own page, and the native side adds what a
//! browser tab cannot: a connect screen that remembers servers, runs or
//! installs Codeman on this machine (`setup`, `local_server`) and installs it
//! on another one over SSH, real full screen, native menus, pop-outs as app
//! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads,
//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP
//! Basic challenges wry leaves out (`macos`).
//!
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
//! only wires those decisions to windows. The main window moves through
//! `policy::Phase`: the connect page (Connect), a server load that has not
//! committed yet (Loading, abandoned after `LOAD_TIMEOUT` or on Cancel), and a
//! committed server page (Loaded). The phase is shared with the web view
//! handlers, which run on this same thread; no `RefCell` borrow of it is ever
//! held across a call into wry or tao.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod assets;
mod config;
mod credentials;
mod local_server;
#[cfg(target_os = "macos")]
mod macos;
mod policy;
mod probe;
mod setup;
use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Command;
use std::rc::Rc;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::json;
use tao::dpi::{LogicalPosition, LogicalSize};
use tao::event::{Event, StartCause, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget};
use tao::window::{Fullscreen, Theme, UserAttentionType, Window, WindowBuilder, WindowId};
use url::Url;
use wry::http::header::AUTHORIZATION;
use wry::http::{HeaderMap, HeaderValue};
use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebViewBuilder};
use config::{Config, Rect, WindowFrame};
use credentials::Keychain;
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via};
use probe::ProbeOutcome;
const APP_NAME: &str = "Codeman";
const VERSION: &str = env!("CARGO_PKG_VERSION");
const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0);
const POPOUT_SIZE: (f64, f64) = (960.0, 680.0);
const MIN_SIZE: (f64, f64) = (480.0, 360.0);
const MAX_POPOUTS: usize = 24;
const SAVE_DEBOUNCE: Duration = Duration::from_secs(1);
/// How long a server load may take to commit before the app gives up on it
/// and brings the connect page back.
const LOAD_TIMEOUT: Duration = Duration::from_secs(15);
/// A window reloads itself with the login at most once in this long after its
/// page reported that its session is gone, so a login the server keeps
/// refusing can never turn into a reload loop.
const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60);
/// A window loads its page again at most once in this long after its web
/// content process ended, so a page that cannot stay up never becomes a reload
/// loop.
const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30);
/// When the app first looks whether the main window made it into the full
/// screen it asked for at launch, how often it asks again, and how many times.
/// tao retries a failed entry only for a window BUILT in full screen, which
/// this one is not (create_main_window), and entering fails while macOS is
/// still switching away from another app's full-screen Space.
const FULLSCREEN_FIRST_CHECK: Duration = Duration::from_millis(700);
const FULLSCREEN_RETRY_EVERY: Duration = Duration::from_millis(500);
const FULLSCREEN_ATTEMPTS: u8 = 6;
const SETTINGS_UNREADABLE: &str = "Settings could not be read, so changes will not be saved this time.";
const LOAD_TIMED_OUT: &str = "Codeman did not open. Check that the server is still running, then connect again.";
const LOAD_CANCELLED: &str = "Opening was cancelled.";
const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter the password again.";
/// A recheck that got 429. The recheck always sends the login, and a
/// single-user Codeman accepts a matching login before its rate limit, so this
/// almost always means the login is wrong; a multi-user Codeman can also refuse
/// a correct one from a busy address, so the text does not say which.
const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \
enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes.";
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
mod menu_id {
pub const SWITCH: &str = "switch-server";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const DEVTOOLS: &str = "devtools";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
}
enum UserEvent {
Menu(MenuEvent),
Ipc {
window: WindowId,
msg: Ipc,
},
ProbeDone(Box<ProbeDone>),
ServerStartDone {
generation: u64,
url: Url,
result: Result<String, String>,
},
/// A `window.open` or `target=_blank` asked for one of the file or
/// download pages Codeman opens that way (policy::new_window, Via::Script).
/// Any frame can ask like this, so the window opens without taking the
/// keyboard, only while the script window budget allows, and nothing is
/// reported back when it does not open.
OpenAppWindow(Url),
/// A page opened another site (window.open); it goes to the browser if the
/// open budget allows.
OpenExternal(Url),
/// The cookies were cleared before a server load (begin_loading).
CookiesCleared {
generation: u64,
url: Url,
},
/// What the server said to the login the challenge handler had sent when
/// it reported a refusal (recheck_login).
LoginChecked {
generation: u64,
outcome: ProbeOutcome,
},
Title {
window: WindowId,
title: String,
},
/// A main-frame navigation committed (wry's PageLoadEvent::Started, from
/// WebKit's didCommitNavigation): `url` is the window's document now.
PageCommitted {
window: WindowId,
url: String,
},
DownloadStarted {
window: WindowId,
url: String,
/// The file name wry chose in ~/Downloads (de-duplicated). wry passes
/// no path when the download ends, so this is the toast's only source.
file_name: Option<String>,
},
DownloadDone {
window: WindowId,
url: String,
path: Option<PathBuf>,
ok: bool,
},
/// The challenge handler sent its login and got challenged again (macOS).
/// Not trusted on its own: recheck_login asks the server first.
AuthRejected,
/// The web content process of a window ended (macOS): a crash, or the
/// system taking it back under memory pressure. The window is blank until
/// something loads in it again (on_content_process_gone).
ContentProcessGone {
window: WindowId,
},
/// A background step for the connect page finished (This Mac status, an
/// install opened in Terminal, the install command copied): the message
/// goes to the page if it is still the one choosing a server.
ToConnectPage(serde_json::Value),
}
struct ProbeDone {
generation: u64,
url: Url,
result: ProbeResult,
/// The Authorization header the probe used (None: no credentials).
auth: Option<String>,
/// The username behind `auth`.
username: Option<String>,
/// The password behind `auth`: typed, from the Keychain, or the local
/// Codeman's own login.
password: Option<String>,
/// Whether the Keychain holds `password` for `username` now, after the
/// worker's own save or delete (update_keychain).
saved: bool,
/// The usernames whose Keychain items for this server the worker deleted.
deleted: Vec<String>,
}
enum ProbeResult {
Probed(ProbeOutcome),
/// The config says a password is saved for this user, but the Keychain
/// gave none (access denied, or the item is gone). No probe was sent: an
/// unauthenticated one would only come back as "needs login".
SavedPasswordUnreadable,
}
enum Kind {
Main,
Popout {
/// The URL the pop-out was opened with, as handed to wry
/// (`policy::webkit_safe_url`), which is also the form WebKit reports
/// for a download.
opened_url: String,
hidden_for_download: bool,
},
}
struct AppWindow {
// Declared first so it is dropped before its window.
webview: WebView,
window: Window,
kind: Kind,
/// The window's last committed URL. Never read from wry's `url()`: that
/// unwraps WKWebView.URL, which is nil until a load commits, and the app
/// would abort.
committed: Option<Url>,
/// When the window last reloaded itself because its session expired.
session_reload_at: Option<Instant>,
/// When the window's web content process last ended.
content_process_gone_at: Option<Instant>,
}
struct AppMenu {
bar: Menu,
window_menu: Submenu,
help_menu: Submenu,
start_fullscreen: CheckMenuItem,
}
enum Load<'a> {
ConnectPage { manual: bool },
Server { url: &'a str, auth: Option<&'a str> },
}
struct App {
proxy: EventLoopProxy<UserEvent>,
config: Config,
/// None: no config directory, or the file could not be read or moved
/// aside, in which case it must not be overwritten.
config_path: Option<PathBuf>,
config_dirty: bool,
last_save: Instant,
/// Where the main window is in reaching a server. Shared with the web view
/// handlers, which run on this same thread.
phase: Rc<RefCell<Phase>>,
/// While Loading: when to give up on the load.
loading_deadline: Option<Instant>,
/// Basic-auth header for (re)loading server pages; the server answers a
/// load with its session cookie, which carries the requests after it.
auth: Option<String>,
/// The username and password behind `auth`, which the challenge handler
/// answers with (set_login).
login: Option<(String, String)>,
/// Counts the logins handed to the challenge handler, so a recheck that
/// comes back after Switch Server or another connect does nothing.
login_generation: u64,
/// The login generation a recheck is running for, if any.
login_recheck: Option<u64>,
windows: HashMap<WindowId, AppWindow>,
main_id: WindowId,
probe_generation: u64,
start_generation: u64,
/// The generation of the Start Codeman run still going, if any.
starting_server: Option<u64>,
/// Shown by the next connect page that asks for its init.
notice: Option<String>,
/// What hands pages to the default browser draws on.
open_budget: OpenBudget,
/// What the app windows a script opens draw on (UserEvent::OpenAppWindow),
/// apart from `open_budget`, so a frame cannot use up the user's links.
script_window_budget: OpenBudget,
/// File names of downloads under way, per window and URL, oldest first.
downloads: HashMap<(WindowId, String), VecDeque<String>>,
menu: AppMenu,
/// The one Keychain worker; every Keychain job goes through its queue.
keychain: Keychain,
/// While the main window may still have to be put into the full screen it
/// asked for at launch: when to look next, and how many retries were made.
fullscreen_retry: Option<(Instant, u8)>,
}
fn main() {
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
{
let menu_proxy = Mutex::new(proxy.clone());
MenuEvent::set_event_handler(Some(move |event| {
if let Ok(p) = menu_proxy.lock() {
let _ = p.send_event(UserEvent::Menu(event));
}
}));
}
#[cfg(target_os = "macos")]
{
// Runs inside a WebKit callback: only post an event.
let auth_proxy = proxy.clone();
macos::set_auth_rejected_handler(Box::new(move || {
let _ = auth_proxy.send_event(UserEvent::AuthRejected);
}));
}
let config_path = Config::default_path();
let (config, can_save) = match config_path.as_deref() {
Some(path) => Config::load_from(path),
None => (Config::default(), true),
};
let config_path = config_path.filter(|_| can_save);
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
let phase = Rc::new(RefCell::new(Phase::Connect));
// Started once: the probe workers and the event loop share this queue,
// and a second one would not keep its jobs in order with the first.
let keychain = Keychain::spawn();
let (window, wants_fullscreen) =
create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}")));
let webview = build_webview(&window, true, Load::ConnectPage { manual: false }, &proxy, &phase)
.unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}")));
#[cfg(target_os = "macos")]
macos::install(&webview);
let main_id = window.id();
let mut windows = HashMap::new();
windows.insert(
main_id,
AppWindow {
webview,
window,
kind: Kind::Main,
committed: None,
session_reload_at: None,
content_process_gone_at: None,
},
);
let mut app = App {
proxy,
config,
config_path,
config_dirty: false,
last_save: Instant::now(),
phase,
loading_deadline: None,
auth: None,
login: None,
login_generation: 0,
login_recheck: None,
windows,
main_id,
probe_generation: 0,
start_generation: 0,
starting_server: None,
notice,
open_budget: OpenBudget::new(),
script_window_budget: OpenBudget::new(),
downloads: HashMap::new(),
menu,
keychain,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
};
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
}
fn fatal(message: &str) -> ! {
eprintln!("codeman-desktop: {message}");
std::process::exit(1);
}
fn build_menu(config: &Config) -> muda::Result<AppMenu> {
let bar = Menu::new();
let about = PredefinedMenuItem::about(
Some("About Codeman"),
Some(AboutMetadata {
name: Some(APP_NAME.into()),
version: Some(VERSION.into()),
comments: Some("A native window for your Codeman server.".into()),
website: Some("https://github.com/Ark0N/Codeman".into()),
..Default::default()
}),
);
let switch = MenuItem::with_id(
menu_id::SWITCH,
"Switch Server\u{2026}",
true,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)),
);
let app_menu = Submenu::with_items(
APP_NAME,
true,
&[
&about,
&PredefinedMenuItem::separator(),
&switch,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
],
)?;
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
let edit = Submenu::with_items(
"Edit",
true,
&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
],
)?;
let start_fullscreen = CheckMenuItem::with_id(
menu_id::START_FULLSCREEN,
"Always Open in Full Screen",
true,
config.start_fullscreen,
None,
);
let view = Submenu::with_items(
"View",
true,
&[
&MenuItem::with_id(
menu_id::RELOAD,
"Reload",
true,
Some(Accelerator::new(Modifiers::META, Code::KeyR)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::fullscreen(None),
&start_fullscreen,
&PredefinedMenuItem::separator(),
&MenuItem::with_id(
menu_id::DEVTOOLS,
"Developer Tools",
true,
Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)),
),
],
)?;
let window_menu = Submenu::with_items(
"Window",
true,
&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
],
)?;
let help_menu = Submenu::with_items(
"Help",
true,
&[
&MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None),
&MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None),
],
)?;
bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?;
Ok(AppMenu {
bar,
window_menu,
help_menu,
start_fullscreen,
})
}
fn monitors(target: &EventLoopWindowTarget<UserEvent>) -> Vec<Rect> {
target
.available_monitors()
.map(|m| {
let sf = m.scale_factor();
let p = m.position().to_logical::<f64>(sf);
let s = m.size().to_logical::<f64>(sf);
Rect {
x: p.x,
y: p.y,
width: s.width,
height: s.height,
}
})
.collect()
}
/// The main window, and whether it was asked into full screen (App's
/// `fullscreen_retry` then makes sure it gets there).
fn create_main_window(
target: &EventLoopWindowTarget<UserEvent>,
config: &Config,
) -> Result<(Window, bool), tao::error::OsError> {
let mut builder = WindowBuilder::new()
.with_title(APP_NAME)
.with_theme(Some(Theme::Dark))
.with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1));
let screens = monitors(target);
match config.window.and_then(|f| f.fitted_to(&screens, MIN_SIZE)) {
Some(f) => {
builder = builder
.with_inner_size(LogicalSize::new(f.width, f.height))
.with_position(LogicalPosition::new(f.x, f.y));
}
None => {
builder = builder.with_inner_size(LogicalSize::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1));
if let Some(m) = target.primary_monitor() {
let sf = m.scale_factor();
let p = m.position().to_logical::<f64>(sf);
let s = m.size().to_logical::<f64>(sf);
let w = DEFAULT_SIZE.0.min(s.width - 40.0);
let h = DEFAULT_SIZE.1.min(s.height - 80.0);
builder = builder
.with_inner_size(LogicalSize::new(w.max(MIN_SIZE.0), h.max(MIN_SIZE.1)))
.with_position(LogicalPosition::new(
p.x + (s.width - w) / 2.0,
p.y + (s.height - h) / 2.0,
));
}
}
}
let window = builder.build(target)?;
// Full screen is entered after the window exists at its windowed frame,
// not set on the builder: a window built straight into full screen gets
// the whole screen as its frame, and that is the frame AppKit puts back
// when the user leaves full screen.
let fullscreen = config.start_fullscreen || config.window.is_some_and(|f| f.fullscreen);
if fullscreen {
window.set_fullscreen(Some(Fullscreen::Borderless(None)));
}
Ok((window, fullscreen))
}
fn auth_headers(auth: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(auth) {
headers.insert(AUTHORIZATION, v);
}
headers
}
/// Hand an http(s) URL to the default browser.
fn open_external(url: &Url) {
#[cfg(target_os = "macos")]
let child = Command::new("/usr/bin/open").arg(url.as_str()).spawn();
#[cfg(target_os = "windows")]
let child = Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", url.as_str()])
.spawn();
#[cfg(all(unix, not(target_os = "macos")))]
let child = Command::new("xdg-open").arg(url.as_str()).spawn();
if let Ok(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
}
/// Give the native challenge handler the login for the server at `url` (its
/// base path included: the handler answers only below it), or take it away
/// (None). It answers HTTP Basic challenges from that server only, so the page
/// recovers by itself when the server restarted and forgot its session. Also
/// re-arms the handler's refusal report.
fn set_challenge_login(login: Option<(&Url, &str, &str)>) {
#[cfg(target_os = "macos")]
macos::set_credentials(login.map(|(url, username, password)| macos::BasicCredentials {
server: url.clone(),
username: username.to_string(),
password: password.to_string(),
}));
#[cfg(not(target_os = "macos"))]
let _ = login;
}
/// Empty the web views' cookie jar, then post CookiesCleared for this load.
/// On macOS the clear is asynchronous and `done` runs later on the main
/// thread; elsewhere the event is posted at once.
fn clear_cookies_then_load(proxy: &EventLoopProxy<UserEvent>, generation: u64, url: Url) {
let proxy = proxy.clone();
let done = move || {
let _ = proxy.send_event(UserEvent::CookiesCleared { generation, url });
};
#[cfg(target_os = "macos")]
macos::clear_cookies(Box::new(done));
#[cfg(not(target_os = "macos"))]
done();
}
/// The phase a commit of `committed` in the main window moves to: Loaded once
/// a page from the server that is loading is the document.
fn loaded_on_commit(phase: &Phase, committed: &str) -> Option<Phase> {
let Phase::Loading(server) = phase else {
return None;
};
let url = Url::parse(committed).ok()?;
policy::same_origin(&url, server).then(|| Phase::Loaded(server.clone()))
}
fn build_webview(
window: &Window,
is_main: bool,
load: Load<'_>,
proxy: &EventLoopProxy<UserEvent>,
phase: &Rc<RefCell<Phase>>,
) -> wry::Result<WebView> {
let id = window.id();
let mut builder = WebViewBuilder::new()
.with_initialization_script(assets::HOST_SHIM)
.with_devtools(true)
.with_accept_first_mouse(true)
.with_back_forward_navigation_gestures(false)
// false = let WebKit take the drop, so Codeman's own HTML5 drop
// (prompt uploads) and <input type=file> drops keep working.
.with_drag_drop_handler(|_| false)
// Ask the user per page instead of wry's default of granting the
// microphone and camera to every frame, sandboxed previews included.
.with_permission_handler(|_| PermissionResponse::Default);
if is_main {
builder = builder.with_custom_protocol(policy::APP_SCHEME.into(), |_id, request| assets::respond(request));
}
// The handlers below read the phase with try_borrow and fail closed: the
// app never holds a borrow across a call into wry, so it cannot fail, but
// a panic here would abort the app.
let nav_phase = phase.clone();
builder = builder.with_navigation_handler(move |target| {
nav_phase
.try_borrow()
.is_ok_and(|phase| policy::navigation(&target, &phase) == Navigation::Allow)
});
// Every answer to WebKit is Deny: the window WebKit would build carries
// none of the app's handlers. What the page asked for is done through the
// event loop. A refusal is not reported back: this path takes requests
// from any frame (wry does not say which), and a report would run in the
// dashboard. Codeman's own pop-outs, the ones it wants to hear about, come
// through CodemanHost.openWindow (on_host_cmd).
let nw_phase = phase.clone();
let nw_proxy = proxy.clone();
builder = builder.with_new_window_req_handler(move |target, _features| {
let decision = match nw_phase.try_borrow() {
Ok(phase) => policy::new_window(&target, &phase, Via::Script),
Err(_) => NewWindow::Deny,
};
let event = match decision {
NewWindow::AppWindow(url) => Some(UserEvent::OpenAppWindow(url)),
NewWindow::External(url) => Some(UserEvent::OpenExternal(url)),
NewWindow::Deny => None,
};
if let Some(event) = event {
let _ = nw_proxy.send_event(event);
}
NewWindowResponse::Deny
});
let ipc_phase = phase.clone();
let ipc_proxy = proxy.clone();
builder = builder.with_ipc_handler(move |request| {
let frame_url = request.uri().to_string();
let msg = match ipc_phase.try_borrow() {
Ok(phase) => policy::parse_ipc(&frame_url, request.body(), &phase, is_main),
Err(_) => None,
};
if let Some(msg) = msg {
let _ = ipc_proxy.send_event(UserEvent::Ipc { window: id, msg });
}
});
let load_phase = phase.clone();
let load_proxy = proxy.clone();
builder = builder.with_on_page_load_handler(move |event, url| {
if !matches!(event, PageLoadEvent::Started) {
return;
}
// Started is didCommitNavigation. The main window's phase moves to
// Loaded right here, so the gates change in the same instant the
// server page becomes the document. App repeats the step when the
// event arrives, in case the borrow was not free.
if is_main {
if let Ok(mut phase) = load_phase.try_borrow_mut() {
if let Some(next) = loaded_on_commit(&phase, &url) {
*phase = next;
}
}
}
let _ = load_proxy.send_event(UserEvent::PageCommitted { window: id, url });
});
let title_proxy = proxy.clone();
builder = builder.with_document_title_changed_handler(move |title| {
let _ = title_proxy.send_event(UserEvent::Title { window: id, title });
});
let dl_proxy = proxy.clone();
builder = builder.with_download_started_handler(move |url, destination| {
// wry already picked ~/Downloads/<suggested name>, de-duplicated; it
// stays as it is. Only its name is passed on, for the toast.
let file_name = destination.file_name().map(|n| n.to_string_lossy().into_owned());
let _ = dl_proxy.send_event(UserEvent::DownloadStarted {
window: id,
url,
file_name,
});
true
});
let done_proxy = proxy.clone();
builder = builder.with_download_completed_handler(move |url, path, ok| {
let _ = done_proxy.send_event(UserEvent::DownloadDone {
window: id,
url,
path,
ok,
});
});
// wry answers WebKit's "web content process ended" notice itself, which
// turns WebKit's own reload off: without a handler the window stays blank.
#[cfg(target_os = "macos")]
{
use wry::WebViewBuilderExtDarwin;
let gone_proxy = proxy.clone();
builder = builder.with_on_web_content_process_terminate_handler(move || {
let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id });
});
}
builder = match load {
Load::ConnectPage { manual: false } => builder.with_url(policy::CONNECT_PAGE),
Load::ConnectPage { manual: true } => builder.with_url(format!("{}?manual=1", policy::CONNECT_PAGE)),
Load::Server { url, auth: Some(auth) } => builder.with_url_and_headers(url, auth_headers(auth)),
Load::Server { url, auth: None } => builder.with_url(url),
};
builder.build(window)
}
impl App {
fn handle(
&mut self,
event: Event<UserEvent>,
target: &EventLoopWindowTarget<UserEvent>,
control_flow: &mut ControlFlow,
) {
match event {
Event::NewEvents(StartCause::Init) => {
#[cfg(target_os = "macos")]
{
self.menu.bar.init_for_nsapp();
self.menu.window_menu.set_as_windows_menu_for_nsapp();
self.menu.help_menu.set_as_help_menu_for_nsapp();
}
}
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow),
Event::UserEvent(event) => self.on_user_event(event, target),
// A click on the Dock icon while no window is visible, which is
// the main window minimized: tao only reports it, so bring the
// window back here.
Event::Reopen {
has_visible_windows: false,
..
} => self.show_main_window(),
Event::MainEventsCleared => self.on_events_cleared(control_flow),
Event::LoopDestroyed => {
self.record_main_frame();
self.save_config();
}
_ => {}
}
}
/// The end of a loop iteration: run what is due, then sleep until the next
/// deadline. tao reads the control flow after this iteration's last event,
/// so it is only decided here, never reset per event.
fn on_events_cleared(&mut self, control_flow: &mut ControlFlow) {
let now = Instant::now();
if self.config_dirty && now >= self.last_save + SAVE_DEBOUNCE {
self.save_config();
}
let loading = matches!(*self.phase.borrow(), Phase::Loading(_));
if !loading {
self.loading_deadline = None;
} else if self.loading_deadline.is_some_and(|due| now >= due) {
self.show_connect_page(Some(LOAD_TIMED_OUT.into()), false);
}
if self.fullscreen_retry.is_some_and(|(due, _)| now >= due) {
self.retry_fullscreen(now);
}
if matches!(*control_flow, ControlFlow::ExitWithCode(_)) {
return;
}
let save_due = self.config_dirty.then(|| self.last_save + SAVE_DEBOUNCE);
let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due);
*control_flow = match [save_due, self.loading_deadline, fullscreen_due]
.into_iter()
.flatten()
.min()
{
Some(due) => ControlFlow::WaitUntil(due),
None => ControlFlow::Wait,
};
}
/// The main window asked for full screen at launch: if it is not there
/// yet, ask again, a few times at most. tao reports a window as full
/// screen from the moment it is asked and puts that back only when the
/// entry fails, so `fullscreen()` being None means it failed. Stops once
/// the window is in full screen or minimized, so a user who leaves full
/// screen later is never pulled back.
fn retry_fullscreen(&mut self, now: Instant) {
let Some((_, attempts)) = self.fullscreen_retry else {
return;
};
self.fullscreen_retry = None;
let Some(main) = self.windows.get(&self.main_id) else {
return;
};
if main.window.fullscreen().is_some() || main.window.is_minimized() || attempts >= FULLSCREEN_ATTEMPTS {
return;
}
main.window.set_fullscreen(Some(Fullscreen::Borderless(None)));
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
}
fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) {
match event {
WindowEvent::CloseRequested => {
if id == self.main_id {
// Closing the main window quits; the server and its
// sessions keep running, so nothing is lost.
self.record_main_frame();
self.save_config();
*control_flow = ControlFlow::Exit;
} else {
self.remove_window(id);
}
}
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
_ => {}
}
}
fn on_user_event(&mut self, event: UserEvent, target: &EventLoopWindowTarget<UserEvent>) {
match event {
UserEvent::Menu(e) => self.on_menu(e.id.0.as_str()),
UserEvent::Ipc { window, msg } => match msg {
Ipc::Host(cmd) => self.on_host_cmd(window, cmd, target),
Ipc::Connect(cmd) => self.on_connect_cmd(cmd),
},
UserEvent::ProbeDone(done) => self.on_probe_done(*done),
UserEvent::ToConnectPage(msg) => {
if matches!(*self.phase.borrow(), Phase::Connect) {
self.to_connect_page(msg);
}
}
UserEvent::ServerStartDone {
generation,
url,
result,
} => {
self.starting_server = None;
if generation != self.start_generation {
return; // cancelled
}
let (ok, output) = match result {
Ok(o) => (true, o),
Err(e) => (false, e),
};
self.to_connect_page(json!({
"type": "serverStartResult",
"ok": ok,
"output": output,
"url": url.as_str(),
}));
}
UserEvent::OpenAppWindow(url) => {
if self.script_window_budget.allow(Instant::now()) {
// Built without focus: it comes to the front, but the
// keyboard stays with the window the user is typing in.
self.build_popout(target, &url, false);
} else {
eprintln!("codeman-desktop: too many windows opened by a page at once; dropped one");
}
}
UserEvent::OpenExternal(url) => {
self.open_external_budgeted(&url);
}
UserEvent::CookiesCleared { generation, url } => {
// Only the load this clear was for, and only while it is
// still wanted: a Cancel, Switch Server, timeout or another
// connect in between moved the generation or the phase on.
let current = generation == self.probe_generation
&& matches!(&*self.phase.borrow(), Phase::Loading(loading) if *loading == url);
if current {
self.load_in(self.main_id, &policy::webkit_safe_url(&url), true);
}
}
UserEvent::LoginChecked { generation, outcome } => self.on_login_checked(generation, outcome),
UserEvent::Title { window, title } => {
if let Some(w) = self.windows.get(&window) {
let title = title.trim();
w.window.set_title(if title.is_empty() { APP_NAME } else { title });
}
}
UserEvent::PageCommitted { window, url } => {
if window == self.main_id {
let next = loaded_on_commit(&self.phase.borrow(), &url);
if let Some(next) = next {
*self.phase.borrow_mut() = next;
}
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
}
}
UserEvent::DownloadStarted { window, url, file_name } => {
// A pop-out opened only to fetch a file would sit empty: hide
// it now, drop it when the download ends (dropping it sooner
// could cancel the download with its web view).
if let Some(w) = self.windows.get_mut(&window) {
if let Kind::Popout {
opened_url,
hidden_for_download,
} = &mut w.kind
{
if *opened_url == url {
*hidden_for_download = true;
w.window.set_visible(false);
}
}
self.downloads
.entry((window, url))
.or_default()
.push_back(file_name.unwrap_or_else(|| "the file".into()));
}
}
UserEvent::DownloadDone { window, url, path, ok } => {
let key = (window, url);
let started = self.downloads.get_mut(&key).and_then(VecDeque::pop_front);
if self.downloads.get(&key).is_some_and(VecDeque::is_empty) {
self.downloads.remove(&key);
}
let name = started
.or_else(|| {
path.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "the file".into());
let server = self.phase.borrow().loaded().cloned();
// The toast goes where the download was started when that
// window still shows Codeman's own app (a session pop-out has
// it too), else to the main window. Chosen before the hidden
// pop-out below is dropped.
let toast_in = server.as_ref().map(|server| {
let shows_app = self.windows.get(&window).is_some_and(|w| {
!matches!(
w.kind,
Kind::Popout {
hidden_for_download: true,
..
}
) && w
.committed
.as_ref()
.is_some_and(|url| policy::trusted_frame(url, server))
});
if shows_app {
window
} else {
self.main_id
}
});
if matches!(
self.windows.get(&window).map(|w| &w.kind),
Some(Kind::Popout {
hidden_for_download: true,
..
})
) {
self.remove_window(window);
}
if let Some(w) = toast_in.and_then(|id| self.windows.get(&id)) {
let _ = w.webview.evaluate_script(&assets::download_toast_script(&name, ok));
}
}
UserEvent::AuthRejected => self.recheck_login(),
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
}
}
/// The web content process of `window` ended, so the window is blank
/// (policy::recovery decides what it does about it). A second end within
/// CRASH_RELOAD_COOLDOWN counts as the same page failing again.
fn on_content_process_gone(&mut self, window: WindowId) {
let now = Instant::now();
let Some(w) = self.windows.get_mut(&window) else {
return;
};
// A pop-out hidden for its download shows no page, and the download
// does not run in that process: loading again would fetch it twice.
if matches!(
w.kind,
Kind::Popout {
hidden_for_download: true,
..
}
) {
return;
}
let again = w
.content_process_gone_at
.is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN);
w.content_process_gone_at = Some(now);
let on_server = self.phase.borrow().loaded().is_some();
match policy::recovery(window == self.main_id, on_server, again) {
Recovery::Reload => {
if let Some((url, with_auth)) = self.reload_target(window) {
self.load_in(window, &url, with_auth);
}
}
Recovery::ConnectPage { gave_up } => {
self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false);
}
Recovery::Close => self.remove_window(window),
Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"),
}
}
/// The challenge handler reported that the server refused its login. That
/// is not taken on trust: a 401 the handler reads as Codeman's can still
/// come from something Codeman relays (a dashboard asset a web tab fetches
/// by a root-absolute URL). So the login is checked once against the
/// server's own status API, off the event loop, and on_login_checked
/// decides.
fn recheck_login(&mut self) {
let Some(server) = self.phase.borrow().server().cloned() else {
return; // the connect page: nothing to recheck
};
let Some(auth) = self.auth.clone() else {
return;
};
let generation = self.login_generation;
if self.login_recheck == Some(generation) {
return;
}
self.login_recheck = Some(generation);
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let outcome = probe::probe(&server, Some(&auth));
let _ = proxy.send_event(UserEvent::LoginChecked { generation, outcome });
});
}
/// The server's answer to recheck_login. A refused login brings the
/// connect page back (without taking focus from another app). So does a
/// 429: the page's own requests then get 429 too, which carries no
/// challenge, so nothing else would ever notice, and re-arming would send
/// the refused login again once the limit lapses. Any other answer, a
/// network error included, leaves the page as it is and hands the handler
/// the same login again, which also re-arms its report, so a later real
/// refusal is noticed.
fn on_login_checked(&mut self, generation: u64, outcome: ProbeOutcome) {
if self.login_recheck == Some(generation) {
self.login_recheck = None;
}
if generation != self.login_generation {
return; // another login, or none, since the recheck started
}
let Some(server) = self.phase.borrow().server().cloned() else {
return;
};
match outcome {
ProbeOutcome::BadCredentials | ProbeOutcome::NeedsLogin => {
self.show_connect_page(Some(AUTH_REJECTED.into()), false);
}
ProbeOutcome::RateLimited { .. } => {
self.show_connect_page(Some(AUTH_RATE_LIMITED.into()), false);
}
_ => {
if let Some((username, password)) = &self.login {
set_challenge_login(Some((&server, username, password)));
}
}
}
}
fn on_menu(&mut self, id: &str) {
match id {
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::RELOAD => self.reload_focused(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
self.config.start_fullscreen = value;
self.config_dirty = true;
self.to_connect_page(json!({ "type": "prefs", "startFullscreen": value }));
}
menu_id::DEVTOOLS => {
if let Some(w) = self.focused_window_id().and_then(|id| self.windows.get(&id)) {
w.webview.open_devtools();
}
}
// The Help items are the user's own click, so they skip the budget.
menu_id::HELP_DOCS => {
open_external(&Url::parse("https://github.com/Ark0N/Codeman/wiki").expect("static url"))
}
menu_id::HELP_GITHUB => open_external(&Url::parse("https://github.com/Ark0N/Codeman").expect("static url")),
_ => {}
}
}
fn on_host_cmd(&mut self, window: WindowId, cmd: HostCmd, target: &EventLoopWindowTarget<UserEvent>) {
match cmd {
HostCmd::OpenWindow(raw) => {
// The page's openWindow already answered true, so every way
// this can fail (refused, the pop-out limit, a window that
// did not build, the browser budget) is reported back to it.
// This request comes only from Codeman's own app shell
// (parse_ipc), so a session it asked for gets its tab back.
let session = self.phase.borrow().loaded().and_then(|server| {
Url::parse(&raw)
.ok()
.and_then(|url| policy::session_id_of(&url, server))
});
let decision = policy::new_window(&raw, &self.phase.borrow(), Via::Host);
let opened = match decision {
NewWindow::AppWindow(url) => self.build_popout(target, &url, true),
NewWindow::External(url) => self.open_external_budgeted(&url),
NewWindow::Deny => false,
};
if !opened {
self.tell_refused(window, session.as_deref());
}
}
HostCmd::CloseWindow => self.remove_window(window),
HostCmd::FocusWindow => {
if let Some(w) = self.windows.get(&window) {
w.window.set_visible(true);
w.window.set_minimized(false);
w.window.set_focus();
}
}
HostCmd::OpenExternal(raw) => {
if let Some(url) = policy::external_url(&raw) {
self.open_external_budgeted(&url);
}
}
HostCmd::SessionExpired => self.on_session_expired(window),
}
}
fn on_connect_cmd(&mut self, cmd: ConnectCmd) {
// The connect page is still live while a server loads, but only
// Cancel and the settings make sense then.
let choosing = matches!(*self.phase.borrow(), Phase::Connect);
let loading = matches!(*self.phase.borrow(), Phase::Loading(_));
match cmd {
ConnectCmd::Ready => self.send_init(),
ConnectCmd::Connect {
url,
username,
password,
remember,
} => {
if choosing {
self.start_probe(url, username, password, remember);
}
}
ConnectCmd::Cancel => {
self.probe_generation += 1;
self.start_generation += 1;
// Only a load that has not committed yet: once the server page
// is up, a Cancel that was still on its way changes nothing.
if loading {
self.show_connect_page(Some(LOAD_CANCELLED.into()), true);
}
}
ConnectCmd::StartServer { url } => {
if choosing {
self.start_server(url);
}
}
ConnectCmd::Forget { url } => {
if let Some(entry) = self.config.forget(&url) {
if let Some(user) = entry.username {
self.keychain.delete(&credentials::account(&url, &user));
}
self.config_dirty = true;
}
self.to_connect_page(json!({ "type": "servers", "servers": self.config.servers }));
}
ConnectCmd::SetAutoConnect(v) => {
self.config.auto_connect = v;
self.config_dirty = true;
}
ConnectCmd::SetStartFullscreen(v) => {
self.config.start_fullscreen = v;
self.menu.start_fullscreen.set_checked(v);
self.config_dirty = true;
}
ConnectCmd::OpenExternal(raw) => {
if let Some(url) = policy::external_url(&raw) {
self.open_external_budgeted(&url);
}
}
ConnectCmd::LocalStatus { quick, deep } => {
if choosing {
let depth = match (quick, deep) {
(true, _) => setup::Depth::Quick,
(false, false) => setup::Depth::Login,
(false, true) => setup::Depth::Interactive,
};
self.in_background(move || json!({ "type": "localStatus", "status": setup::inspect(depth) }));
}
}
ConnectCmd::InstallLocal { network } => {
if choosing {
self.in_background(move || {
let port = setup::pick_install_port();
let script = setup::local_install_script(setup::LocalInstall { port, network });
match setup::open_in_terminal(setup::LOCAL_SCRIPT_NAME, &script) {
Ok(()) => json!({ "type": "installStarted", "port": port }),
Err(message) => json!({ "type": "installFailed", "message": message }),
}
});
}
}
ConnectCmd::InstallRemote { target } => {
if choosing {
match setup::SshTarget::parse(&target) {
Err(message) => self.to_connect_page(json!({
"type": "installFailed",
"remote": true,
"message": message,
})),
Ok(target) => self.in_background(move || {
let script = setup::remote_install_script(&target);
match setup::open_in_terminal(setup::REMOTE_SCRIPT_NAME, &script) {
Ok(()) => json!({
"type": "remoteInstallStarted",
"host": target.host,
"url": target.suggested_url(),
}),
Err(message) => json!({ "type": "installFailed", "remote": true, "message": message }),
}
}),
}
}
}
ConnectCmd::CopyInstallCommand => {
self.in_background(|| match setup::copy_to_clipboard(setup::INSTALL_ONE_LINER) {
Ok(()) => json!({ "type": "copied" }),
Err(message) => json!({ "type": "installFailed", "remote": true, "message": message }),
});
}
}
}
/// Run `work` off the event loop (it may run a login shell, plutil,
/// Terminal or a probe) and hand what it returns to the connect page.
fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) {
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let _ = proxy.send_event(UserEvent::ToConnectPage(work()));
});
}
fn send_init(&mut self) {
let notice = self.notice.take();
self.to_connect_page(json!({
"type": "init",
"servers": self.config.servers,
"autoConnect": self.config.auto_connect,
"startFullscreen": self.config.start_fullscreen,
"version": VERSION,
"starting": self.start_running(),
"notice": notice,
}));
}
/// Whether a Start Codeman run is going whose result the page will get.
fn start_running(&self) -> bool {
self.starting_server == Some(self.start_generation)
}
fn start_probe(&mut self, raw: String, username: Option<String>, password: Option<String>, remember: bool) {
let url = match policy::normalize_server_url(&raw) {
Ok(u) => u,
Err(message) => {
self.to_connect_page(json!({ "type": "error", "message": message }));
return;
}
};
let key = url.to_string();
let typed_user = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
let typed_password = password.filter(|p| !p.is_empty());
let entry = self.config.find(&key);
// The user the config names for this server now. Its Keychain item
// goes once the probe accepts another one (update_keychain).
let previous_user = entry.and_then(|e| e.username.clone());
let user = typed_user.or_else(|| previous_user.clone());
// The config says the Keychain holds this user's password.
let expect_saved = typed_password.is_none()
&& user.is_some()
&& entry.is_some_and(|e| e.has_saved_password && e.username == user);
self.probe_generation += 1;
let generation = self.probe_generation;
let proxy = self.proxy.clone();
let keychain = self.keychain.clone();
// Every Keychain call for the connect happens here, never on the
// event loop: any of them can bring up the system's access prompt,
// which must not hold up the window.
std::thread::spawn(move || {
let saved = match typed_password {
Some(_) => None,
None => user
.as_ref()
.and_then(|u| keychain.load_blocking(&credentials::account(&key, u))),
};
let (user, password, source) = match typed_password {
Some(p) => (user, Some(p), PasswordSource::Typed),
None => match saved {
Some(p) => (user, Some(p), PasswordSource::Keychain),
// The Codeman on this Mac keeps its login where Codeman's
// own clients find it (setup::local_login: its launchd
// job, then ~/.codeman/.env), and only answers it at its
// own loopback address. Used when nothing was typed and
// the Keychain has nothing for this server, for the same
// username only, and never saved anywhere.
None if !expect_saved => match setup::local_login(&url) {
Some((u, p)) if user.as_deref().is_none_or(|current| current == u) => {
(Some(u), Some(p), PasswordSource::LocalServer)
}
_ => (user, None, PasswordSource::Absent),
},
None => (user, None, PasswordSource::Absent),
},
};
// Codeman's default username when only a password was given.
let username = password
.as_ref()
.map(|_| user.clone().unwrap_or_else(|| "admin".into()));
let auth = match (&username, &password) {
(Some(u), Some(p)) => Some(policy::basic_auth_header(u, p)),
_ => None,
};
let (result, update) = if expect_saved && password.is_none() {
(ProbeResult::SavedPasswordUnreadable, KeychainUpdate::default())
} else {
let outcome = probe::probe(&url, auth.as_deref());
let login = ProbedLogin {
server: &key,
previous_user: previous_user.as_deref(),
username: username.as_deref(),
password: password.as_deref(),
source,
remember,
};
let update = update_keychain(&keychain, &outcome, &login);
(ProbeResult::Probed(outcome), update)
};
let _ = proxy.send_event(UserEvent::ProbeDone(Box::new(ProbeDone {
generation,
url,
result,
auth,
username,
password,
saved: update.saved,
deleted: update.deleted,
})));
});
}
fn on_probe_done(&mut self, done: ProbeDone) {
let key = done.url.to_string();
let choosing = matches!(*self.phase.borrow(), Phase::Connect);
if done.generation != self.probe_generation || !choosing {
// Cancelled, superseded, or a server is already loading. What the
// worker did to the Keychain stands all the same, so an entry
// whose password it deleted stops claiming one.
self.note_deleted_passwords(&key, &done.deleted);
return;
}
match done.result {
ProbeResult::SavedPasswordUnreadable => {
// needsPassword: the page shows the login fields, which the
// This Mac tab does not have.
self.to_connect_page(json!({ "type": "error", "message": KEYCHAIN_UNREADABLE, "needsPassword": true }));
}
ProbeResult::Probed(ProbeOutcome::Ok { version }) => {
let orphans = self.config.remember(&key, done.username.as_deref(), done.saved);
self.delete_accounts(&orphans);
self.config_dirty = true;
self.to_connect_page(json!({ "type": "opening", "version": version }));
self.begin_loading(done.url, done.auth, done.username, done.password);
}
ProbeResult::Probed(ProbeOutcome::PasswordChangeRequired) => {
// The worker already deleted the one-time password and any
// older one saved for the account (update_keychain).
let orphans = self.config.remember(&key, done.username.as_deref(), false);
self.delete_accounts(&orphans);
self.config_dirty = true;
self.to_connect_page(json!({ "type": "opening", "version": null, "passwordChange": true }));
self.begin_loading(done.url, done.auth, done.username, done.password);
}
ProbeResult::Probed(outcome) => {
let can_start = outcome == ProbeOutcome::Refused && policy::can_start_locally(&done.url);
self.to_connect_page(json!({
"type": "probeResult",
"url": done.url.as_str(),
"outcome": outcome,
"canStart": can_start,
}));
}
}
}
/// Point the main window at the server. The cookies go first: a session
/// cookie an earlier login left (another user, a Forget, a password
/// changed since) would win over this login's Authorization header, since
/// Codeman checks the cookie first, and the first load mints a fresh one
/// from the header anyway. The load itself starts once the clear is done
/// (UserEvent::CookiesCleared). The connect page stays the live document
/// until the server page commits; on_events_cleared gives up after
/// LOAD_TIMEOUT, which counts from here, so it covers the clear too.
fn begin_loading(&mut self, url: Url, auth: Option<String>, username: Option<String>, password: Option<String>) {
// Set before the load: its first response may already be a challenge.
self.set_login(username.zip(password).map(|(u, p)| (&url, u, p)));
self.auth = auth;
// Before the load: the navigation handler allows the server's origin
// only once the phase names it.
*self.phase.borrow_mut() = Phase::Loading(url.clone());
self.loading_deadline = Some(Instant::now() + LOAD_TIMEOUT);
clear_cookies_then_load(&self.proxy, self.probe_generation, url);
}
/// Hand the challenge handler a new login, as (server, username,
/// password), or none, and start a new login generation, so a recheck of
/// the one before it is ignored when it comes back.
fn set_login(&mut self, login: Option<(&Url, String, String)>) {
set_challenge_login(login.as_ref().map(|(server, u, p)| (*server, u.as_str(), p.as_str())));
self.login = login.map(|(_, u, p)| (u, p));
self.login_generation += 1;
}
fn start_server(&mut self, raw: String) {
if let Some(running) = self.starting_server {
// A start is still going, maybe one the page cancelled: its result
// goes to the page that asked again.
self.start_generation = running;
self.to_connect_page(json!({ "type": "serverStarting" }));
return;
}
let url = match policy::normalize_server_url(&raw) {
Ok(u) if policy::can_start_locally(&u) => u,
Ok(_) => {
self.to_connect_page(json!({ "type": "error", "message": START_REFUSED }));
return;
}
Err(message) => {
self.to_connect_page(json!({ "type": "error", "message": message }));
return;
}
};
self.start_generation += 1;
let generation = self.start_generation;
self.starting_server = Some(generation);
self.to_connect_page(json!({ "type": "serverStarting" }));
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let result = setup::start_local(&url);
let _ = proxy.send_event(UserEvent::ServerStartDone {
generation,
url,
result,
});
});
}
/// Leave whatever the main window shows for the connect page: Switch
/// Server, a load that failed or was cancelled, or a refused login.
/// `notice` is shown by that page once it asks for its init.
///
/// `front` is for what the user just did (Switch Server, Reload, Cancel):
/// the main window comes forward and takes the keyboard. A timeout or a
/// refused login can happen while the user works in another app, so then
/// the window stays where it is and the Dock icon bounces once instead,
/// unless the window is already the one in use.
fn show_connect_page(&mut self, notice: Option<String>, front: bool) {
self.probe_generation += 1;
let popouts: Vec<WindowId> = self.windows.keys().copied().filter(|id| *id != self.main_id).collect();
for id in popouts {
self.remove_window(id);
}
// Before the load: the navigation handler allows the connect page
// only while the phase says it is live.
*self.phase.borrow_mut() = Phase::Connect;
self.loading_deadline = None;
self.auth = None;
self.set_login(None);
self.notice = notice;
if let Some(main) = self.windows.get_mut(&self.main_id) {
main.session_reload_at = None;
let _ = main.webview.load_url(&format!("{}?manual=1", policy::CONNECT_PAGE));
main.window.set_title(APP_NAME);
if front {
main.window.set_visible(true);
main.window.set_focus();
} else if !main.window.is_focused() || main.window.is_minimized() {
main.window
.request_user_attention(Some(UserAttentionType::Informational));
}
}
}
fn show_main_window(&self) {
if let Some(main) = self.windows.get(&self.main_id) {
main.window.set_minimized(false);
main.window.set_visible(true);
main.window.set_focus();
}
}
/// The URL a server window reloads, handed to wry as is, and whether the
/// login goes with it. Main window: its committed page if that is
/// Codeman's own app shell, else the server root (a raw file that
/// replaced the window, for example). Pop-out: its committed page, or the
/// URL it was opened with when nothing committed yet. The login goes only
/// below the server's base path.
fn reload_target(&self, id: WindowId) -> Option<(String, bool)> {
let server = self.phase.borrow().loaded().cloned()?;
let w = self.windows.get(&id)?;
let target = match &w.kind {
Kind::Main => match &w.committed {
Some(current) if policy::trusted_frame(current, &server) => current.clone(),
_ => server.clone(),
},
Kind::Popout { opened_url, .. } => w.committed.clone().or_else(|| Url::parse(opened_url).ok())?,
};
let with_auth = policy::within_base(&target, &server);
Some((policy::webkit_safe_url(&target), with_auth))
}
/// Load `url` in window `id`, with the login when `with_auth` and there is
/// one.
fn load_in(&self, id: WindowId, url: &str, with_auth: bool) {
let Some(w) = self.windows.get(&id) else {
return;
};
let result = match self.auth.as_deref().filter(|_| with_auth) {
Some(auth) => w.webview.load_url_with_headers(url, auth_headers(auth)),
None => w.webview.load_url(url),
};
if let Err(e) = result {
eprintln!("codeman-desktop: could not load a page: {e}");
}
}
fn reload_focused(&mut self) {
let Some(id) = self.focused_window_id() else {
return;
};
let loaded = self.phase.borrow().loaded().is_some();
if id == self.main_id && !loaded {
// The connect page, or a server load that has not committed:
// start over on the connect page.
self.show_connect_page(None, true);
return;
}
if let Some((url, with_auth)) = self.reload_target(id) {
self.load_in(id, &url, with_auth);
}
}
/// The page in `window` got a 401 from its own server (the HOST_SHIM
/// fallback for when the challenge handler did not recover it): reload it
/// once with the login, never again within SESSION_RELOAD_COOLDOWN.
fn on_session_expired(&mut self, window: WindowId) {
if self.auth.is_none() {
return;
}
let now = Instant::now();
let recent = self.windows.get(&window).is_some_and(|w| {
w.session_reload_at
.is_some_and(|at| now.saturating_duration_since(at) < SESSION_RELOAD_COOLDOWN)
});
if recent {
return;
}
let Some((url, true)) = self.reload_target(window) else {
return;
};
if let Some(w) = self.windows.get_mut(&window) {
w.session_reload_at = Some(now);
}
self.load_in(window, &url, true);
}
/// Open one of Codeman's pop-out pages in an app window. False when it
/// did not open: the pop-out limit, no server loaded, a URL that is not a
/// pop-out page, or a window or web view that failed to build. `focused`:
/// the window takes the keyboard; otherwise it comes to the front without
/// it.
fn build_popout(&mut self, target: &EventLoopWindowTarget<UserEvent>, url: &Url, focused: bool) -> bool {
let Some(server) = self.phase.borrow().loaded().cloned() else {
return false;
};
// The callers only pass what policy::new_window made an AppWindow;
// checked again here, where the window is built, so no other path
// can ever open a proxied dashboard or another page top-level.
if !policy::is_popout_target(url, &server) {
return false;
}
let popouts = self
.windows
.values()
.filter(|w| matches!(w.kind, Kind::Popout { .. }))
.count();
if popouts >= MAX_POPOUTS {
return false;
}
let opened_url = policy::webkit_safe_url(url);
let auth = self.auth.clone().filter(|_| policy::within_base(url, &server));
let window = match WindowBuilder::new()
.with_title(APP_NAME)
.with_theme(Some(Theme::Dark))
.with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1))
.with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1))
.with_focused(focused)
.build(target)
{
Ok(window) => window,
Err(e) => {
eprintln!("codeman-desktop: could not open a window: {e}");
return false;
}
};
let load = Load::Server {
url: &opened_url,
auth: auth.as_deref(),
};
let webview = match build_webview(&window, false, load, &self.proxy, &self.phase) {
Ok(webview) => webview,
Err(e) => {
eprintln!("codeman-desktop: could not create a web view: {e}");
return false;
}
};
#[cfg(target_os = "macos")]
macos::install(&webview);
self.windows.insert(
window.id(),
AppWindow {
webview,
window,
kind: Kind::Popout {
opened_url,
hidden_for_download: false,
},
committed: None,
session_reload_at: None,
content_process_gone_at: None,
},
);
true
}
/// Tell the page in `window` that the window it asked for through
/// CodemanHost.openWindow was not opened (assets::refused_script), with
/// the session id when that was a session pop-out, so the dashboard takes
/// the session's tab back. Only for that request, which parse_ipc takes
/// from Codeman's own app shell alone.
fn tell_refused(&self, window: WindowId, session_id: Option<&str>) {
if let Some(w) = self.windows.get(&window) {
let _ = w.webview.evaluate_script(&assets::refused_script(session_id));
}
}
/// Hand a URL to the default browser if the open budget allows; a page
/// can call window.open without a click, so openings over budget are
/// dropped.
fn open_external_budgeted(&mut self, url: &Url) -> bool {
if !self.open_budget.allow(Instant::now()) {
eprintln!(
"codeman-desktop: too many outside links at once; dropped one to {}",
url.host_str().unwrap_or("?")
);
return false;
}
open_external(url);
true
}
/// Close a pop-out (never the main window) and forget its downloads.
fn remove_window(&mut self, id: WindowId) {
if id == self.main_id {
return;
}
self.windows.remove(&id);
self.downloads.retain(|(window, _), _| *window != id);
}
/// The app window that is key. None while a window that is not ours is
/// (the About panel, a detached inspector): Reload and Developer Tools
/// then do nothing rather than act on a window behind it.
fn focused_window_id(&self) -> Option<WindowId> {
self.windows
.iter()
.find(|(_, w)| w.window.is_focused())
.map(|(id, _)| *id)
}
/// Message the connect page (only while it is the main window's live
/// document).
fn to_connect_page(&self, msg: serde_json::Value) {
if !self.phase.borrow().connect_page_live() {
return;
}
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&format!(
"window.__codemanDesktop && window.__codemanDesktop.onNative({msg})"
));
}
}
fn record_main_frame(&mut self) {
let Some(main) = self.windows.get(&self.main_id) else {
return;
};
let w = &main.window;
let fullscreen = w.fullscreen().is_some();
let mut frame = self.config.window.unwrap_or(WindowFrame {
x: 0.0,
y: 0.0,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
fullscreen,
});
// In full screen or minimized the live geometry is not the windowed
// frame; the saved one is kept for the next launch.
if !fullscreen && !w.is_minimized() {
let sf = w.scale_factor();
// On macOS tao's with_position places the CONTENT area's top-left,
// so that is the corner saved; elsewhere it is the outer one.
#[cfg(target_os = "macos")]
let position = w.inner_position();
#[cfg(not(target_os = "macos"))]
let position = w.outer_position();
if let Ok(pos) = position {
let p = pos.to_logical::<f64>(sf);
frame.x = p.x;
frame.y = p.y;
}
// Not tao's inner_size(): wry replaced the window's content view,
// and tao reads the size of its own view, which stays at the
// creation size. On macOS not the web view's bounds either: a
// docked Web Inspector shrinks the web view, not the window. The
// content size is in points already.
#[cfg(target_os = "macos")]
let size = macos::content_size(w);
#[cfg(not(target_os = "macos"))]
let size = None;
let size = size.or_else(|| {
let bounds = main.webview.bounds().ok()?;
let s = bounds.size.to_logical::<f64>(sf);
Some((s.width, s.height))
});
if let Some((width, height)) = size {
frame.width = width;
frame.height = height;
}
}
// While the launch's full screen may still be retried, a window not
// in full screen yet keeps the flag that was saved (or asked for).
frame.fullscreen = if self.fullscreen_retry.is_some() && !fullscreen {
self.config.window.is_none_or(|f| f.fullscreen)
} else {
fullscreen
};
if self.config.window != Some(frame) {
self.config.window = Some(frame);
self.config_dirty = true;
}
}
fn save_config(&mut self) {
self.last_save = Instant::now();
self.config_dirty = false;
if let Some(path) = &self.config_path {
if let Err(e) = self.config.save_to(path) {
eprintln!("codeman-desktop: could not save {}: {e}", path.display());
}
}
}
/// Queue the deletion of the Keychain items of accounts the config no
/// longer lists, as (server url, username).
fn delete_accounts(&self, accounts: &[(String, String)]) {
for (url, user) in accounts {
self.keychain.delete(&credentials::account(url, user));
}
}
/// The probe worker deleted the Keychain items of `users` for the server
/// `url`: an entry that names one of them no longer has a password saved.
fn note_deleted_passwords(&mut self, url: &str, users: &[String]) {
for entry in self.config.servers.iter_mut().filter(|s| s.url == url) {
if entry.has_saved_password && entry.username.as_ref().is_some_and(|u| users.contains(u)) {
entry.has_saved_password = false;
self.config_dirty = true;
}
}
}
}
/// A login the probe just tried, and what the user asked for it.
struct ProbedLogin<'a> {
/// The server, as the config and the Keychain accounts key it.
server: &'a str,
/// The username the config named for this server before the connect.
previous_user: Option<&'a str>,
username: Option<&'a str>,
password: Option<&'a str>,
/// Where the password came from.
source: PasswordSource,
remember: bool,
}
/// Where a connect's password came from, which decides what the Keychain
/// does with it (update_keychain).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PasswordSource {
/// Typed on the connect page this time: saved if Remember is on.
Typed,
/// From the Keychain: there already.
Keychain,
/// The local Codeman's own login (setup::local_login): it stays where it
/// is, and the Keychain never gets a copy.
LocalServer,
/// No password.
Absent,
}
/// What update_keychain did: whether the Keychain now holds the login's
/// password, and whose items it deleted.
#[derive(Default)]
struct KeychainUpdate {
saved: bool,
deleted: Vec<String>,
}
/// Bring the Keychain in line with a probe the server answered, on the probe
/// worker. Only an accepted login changes anything (Ok, or a password the
/// server wants changed), since only then does the config get the new entry.
///
/// When that entry names another user than the config did (compared exactly,
/// as the account string is), the old user's item is deleted FIRST, before any
/// save: the Keychain worker runs jobs in order, so a Keychain that treats two
/// spellings of a name as one item cannot lose the new password to that
/// delete. Then the typed password is saved when Remember is on and deleted
/// when it is off, and a password the server wants changed is never kept.
fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate {
let mut update = KeychainUpdate::default();
if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) {
return update;
}
let mut delete = |user: &str| {
keychain.delete(&credentials::account(login.server, user));
update.deleted.push(user.to_string());
};
if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) {
delete(old);
}
let saved = match (outcome, login.username, login.password) {
(ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => {
if login.remember {
keychain
.save_blocking(&credentials::account(login.server, user), password)
.is_ok()
} else {
delete(user);
false
}
}
// A password that came from the Keychain is saved there already.
(ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain,
(ProbeOutcome::PasswordChangeRequired, Some(user), _) => {
delete(user);
false
}
_ => false,
};
update.saved = saved;
update
}