Files
Codeman/packages/desktop/src/main.rs
T

1845 lines
76 KiB
Rust
Raw Normal View History

//! 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, 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);
/// 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 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,
/// 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>,
}
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,
},
);
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,
});
});
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(),
}
}
/// 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,
},
);
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
}