From 340e6bfff11f64a8832354dc315066840b7adfdb Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 16 Sep 2026 18:58:49 +0200 Subject: [PATCH] feat(KAN-52): add server selection and isolated native sessions --- native/src-tauri/src/lib.rs | 452 ++++++++++++++++++++------ native/src-tauri/src/locale.rs | 5 + native/src-tauri/src/server.rs | 137 ++++++++ native/src-tauri/tauri.conf.json | 2 +- native/src-tauri/tests/macos_smoke.rs | 164 ++++++---- native/tests/launcher-browser.mjs | 148 +++++++++ native/tests/launcher.test.js | 45 ++- native/ui/index.html | 15 +- native/ui/locales/en.js | 23 +- native/ui/main.js | 78 +++++ native/ui/server.js | 33 ++ native/ui/styles.css | 53 +++ tests/Fixtures/auth-browser.mjs | 16 +- 13 files changed, 988 insertions(+), 183 deletions(-) create mode 100644 native/src-tauri/src/locale.rs create mode 100644 native/src-tauri/src/server.rs create mode 100644 native/tests/launcher-browser.mjs create mode 100644 native/ui/server.js diff --git a/native/src-tauri/src/lib.rs b/native/src-tauri/src/lib.rs index 30479de..ec24cda 100644 --- a/native/src-tauri/src/lib.rs +++ b/native/src-tauri/src/lib.rs @@ -1,9 +1,39 @@ +mod locale; +mod server; + +use locale::en; +use server::{validate_url, Server, DEFAULT_SERVER}; +use std::{path::PathBuf, sync::Mutex, time::Duration}; use tauri::{ + menu::{MenuBuilder, SubmenuBuilder}, webview::{NewWindowResponse, PageLoadEvent}, AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; -const SERVER_URL: &str = "https://kanbangit.de/"; +#[derive(Default)] +struct Connection { + server: Option, + label: Option, + revision: u64, + ready: bool, + phase: &'static str, +} + +impl Connection { + fn begin_navigation(&mut self) -> u64 { + self.revision += 1; + self.phase = "connecting"; + self.revision + } + + fn timeout(&mut self, revision: u64) -> bool { + if self.revision != revision || self.phase != "connecting" { + return false; + } + self.phase = "connectionError"; + true + } +} fn allows_server_navigation(server: &Url, destination: &Url) -> bool { destination.scheme() == "https" @@ -20,81 +50,298 @@ fn allows_launcher_navigation(destination: &Url) -> bool { && destination.password().is_none() } +fn configuration_path(app: &AppHandle) -> tauri::Result { + Ok(app.path().app_config_dir()?.join("active-server")) +} + +fn update_launcher(app: &AppHandle) { + let state = app.state::>(); + let state = state.lock().unwrap(); + let mut message = Url::parse("tauri://localhost/").unwrap(); + message + .query_pairs_mut() + .append_pair( + "server", + state + .server + .as_ref() + .map_or(DEFAULT_SERVER, |server| server.url.as_str()), + ) + .append_pair("phase", state.phase) + .append_pair("ready", if state.ready { "true" } else { "false" }); + if let Some(window) = app.get_webview_window("launcher") { + let _ = window.eval(format!( + "window.updateLauncher?.('{}')", + message.query().unwrap() + )); + } +} + fn show_launcher(app: &AppHandle) { + update_launcher(app); if let Some(window) = app.get_webview_window("launcher") { let _ = window.show(); let _ = window.set_focus(); } } -fn open_workspace(app: &AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window("workspace") { - window.show()?; - window.set_focus()?; - return Ok(()); - } - - let server = Url::parse(SERVER_URL).expect("The built-in server URL must be valid"); - let window = WebviewWindowBuilder::new(app, "workspace", WebviewUrl::External(server.clone())) - .title("KanbanGit") - .inner_size(1280.0, 850.0) - .min_inner_size(375.0, 600.0) - .center() - .on_navigation(move |destination| allows_server_navigation(&server, destination)) - .on_new_window(|_, _| NewWindowResponse::Deny) - .on_page_load(|window, payload| { - if payload.event() == PageLoadEvent::Finished { - if let Some(launcher) = window.app_handle().get_webview_window("launcher") { - let _ = launcher.hide(); - } - #[cfg(debug_assertions)] - eprintln!( - "KanbanGit workspace page loaded: {}", - payload.url().origin().ascii_serialization() - ); - } - }) - .build()?; - +fn begin_navigation(app: &AppHandle, label: &str) { + let state = app.state::>(); + let revision = { + let mut state = state.lock().unwrap(); + if state.label.as_deref() != Some(label) { + return; + } + state.begin_navigation() + }; + update_launcher(app); let handle = app.clone(); - window.on_window_event(move |event| { - if matches!(event, WindowEvent::Destroyed) { + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(15)); + let state = handle.state::>(); + let timed_out = state.lock().unwrap().timeout(revision); + if timed_out { show_launcher(&handle); } }); +} + +fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { + let url = validate_url(input)?; + let state = app.state::>(); + let (server, old_label, label) = { + let mut state = state.lock().unwrap(); + let same_server = state + .server + .as_ref() + .is_some_and(|server| server.url == url); + if same_server && state.ready { + if let Some(window) = state + .label + .as_ref() + .and_then(|label| app.get_webview_window(label)) + { + window.show().map_err(|_| "connectionError")?; + window.set_focus().map_err(|_| "connectionError")?; + return Ok(()); + } + } + let server = if same_server { + state.server.clone().unwrap() + } else { + Server::new(url).map_err(|_| "storageError")? + }; + server + .save(&configuration_path(app).map_err(|_| "storageError")?) + .map_err(|_| "storageError")?; + let old_label = state.label.take(); + state.server = Some(server.clone()); + state.ready = false; + state.revision += 1; + let label = format!("workspace-{}", state.revision); + state.label = Some(label.clone()); + (server, old_label, label) + }; + if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) { + window.close().map_err(|_| "connectionError")?; + } + begin_navigation(app, &label); + let handle = app.clone(); + let navigation_label = label.clone(); + let server_url = server.url.clone(); + let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url)) + .title(en::APP_NAME) + .inner_size(1280.0, 850.0) + .min_inner_size(375.0, 600.0) + .center() + .visible(false) + .data_store_identifier(server.profile) + .on_navigation(move |destination| { + if !allows_server_navigation(&server_url, destination) { + return false; + } + begin_navigation(&handle, &navigation_label); + true + }) + .on_new_window(|_, _| NewWindowResponse::Deny) + .on_page_load(|window, payload| { + if payload.event() != PageLoadEvent::Finished { + return; + } + let app = window.app_handle(); + let state = app.state::>(); + let should_show = { + let mut state = state.lock().unwrap(); + if state.label.as_deref() != Some(window.label()) { + return; + } + let first_load = !state.ready; + state.ready = true; + state.phase = "connected"; + first_load + }; + update_launcher(app); + if should_show { + let _ = window.show(); + let _ = window.set_focus(); + if let Some(launcher) = app.get_webview_window("launcher") { + let _ = launcher.hide(); + } + } + }) + .build() + .map_err(|_| "connectionError")?; + let handle = app.clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Destroyed) { + let state = handle.state::>(); + let current = { + let mut state = state.lock().unwrap(); + if state.label.as_deref() == Some(&label) { + state.label = None; + state.ready = false; + state.revision += 1; + state.phase = "idle"; + true + } else { + false + } + }; + if current { + show_launcher(&handle); + } + } + }); Ok(()) } +fn handle_launcher_action(app: &AppHandle, destination: &Url) { + let result = match destination.path() { + "/action/open" => { + let input = destination + .query_pairs() + .find(|(key, _)| key == "server") + .map(|(_, value)| value.into_owned()) + .unwrap_or_default(); + open_workspace(app, &input) + } + "/action/resume" => { + let label = app + .state::>() + .lock() + .unwrap() + .label + .clone(); + if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) { + let _ = window.show(); + let _ = window.set_focus(); + } + Ok(()) + } + _ => Ok(()), + }; + if let Err(error) = result { + app.state::>().lock().unwrap().phase = error; + show_launcher(app); + } +} + pub fn context() -> tauri::Context { tauri::generate_context!() } pub fn builder() -> tauri::Builder { tauri::Builder::default().setup(|app| { - let handle = app.handle().clone(); - WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) - .title("KanbanGit") - .inner_size(560.0, 520.0) - .min_inner_size(375.0, 480.0) - .center() - .on_navigation(move |destination| { - if allows_launcher_navigation(destination) { - return true; - } - if destination.as_str() == SERVER_URL { - let handle = handle.clone(); - // Defer window creation until the navigation callback has returned. - let _ = handle.clone().run_on_main_thread(move || { - if let Err(error) = open_workspace(&handle) { - eprintln!("Could not open the workspace: {error}"); - show_launcher(&handle); - } - }); - } - false - }) - .on_new_window(|_, _| NewWindowResponse::Deny) + let (server, phase) = match Server::load(&configuration_path(app.handle())?) { + Ok(server) => (server, "idle"), + Err(_) => (None, "storageError"), + }; + app.manage(Mutex::new(Connection { + server, + phase, + ..Default::default() + })); + let menu = MenuBuilder::new(app) + .item( + &SubmenuBuilder::new(app, en::APP_NAME) + .text("server-settings", en::SERVER_SETTINGS) + .separator() + .quit() + .build()?, + ) + .item( + &SubmenuBuilder::new(app, en::EDIT) + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?, + ) .build()?; + app.set_menu(menu)?; + app.on_menu_event(|app, event| { + if event.id().as_ref() == "server-settings" { + show_launcher(app); + } + }); + let handle = app.handle().clone(); + let launcher = + WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) + .title(en::APP_NAME) + .inner_size(560.0, 720.0) + .min_inner_size(375.0, 640.0) + .center() + .on_navigation(move |destination| { + if !allows_launcher_navigation(destination) { + return false; + } + if destination.path().starts_with("/action/") { + let handle = handle.clone(); + let destination = destination.clone(); + let _ = handle.clone().run_on_main_thread(move || { + handle_launcher_action(&handle, &destination) + }); + return false; + } + matches!(destination.path(), "" | "/" | "/index.html") + }) + .on_new_window(|_, _| NewWindowResponse::Deny) + .on_page_load(|window, payload| { + if payload.event() == PageLoadEvent::Finished { + let app = window.app_handle().clone(); + update_launcher(&app); + std::thread::spawn(move || { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + let server = handle + .state::>() + .lock() + .unwrap() + .server + .clone(); + if let Some(server) = server { + if let Err(error) = open_workspace(&handle, server.url.as_str()) + { + handle.state::>().lock().unwrap().phase = + error; + show_launcher(&handle); + } + } + }); + }); + } + }) + .build()?; + let window = launcher.clone(); + launcher.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + }); Ok(()) }) } @@ -109,72 +356,75 @@ mod tests { use super::*; #[test] - fn server_allows_application_routes_on_the_same_https_origin() { - let server = Url::parse(SERVER_URL).unwrap(); + fn navigation_is_limited_to_the_selected_https_origin() { + let server = validate_url("https://example.com:8443/app/").unwrap(); for path in [ - "/", "/login", "/two-factor-challenge", - "/projects/1?ticket=51#details", + "/projects/1?ticket=52#details", ] { assert!(allows_server_navigation( &server, &server.join(path).unwrap() )); } - } - - #[test] - fn server_rejects_origin_changes_downgrades_credentials_and_local_content() { - let server = Url::parse(SERVER_URL).unwrap(); for destination in [ - "http://kanbangit.de/", - "https://kanbangit.de:444/", - "https://kanbangit.de.example.com/", + "http://example.com:8443/", "https://example.com/", - "https://user:password@kanbangit.de/", - "https://user@kanbangit.de/", - "tauri://localhost/index.html", + "https://other.example.com:8443/", + "https://user:secret@example.com:8443/", + "tauri://localhost/action/open?server=https://evil.test", "file:///etc/passwd", "data:text/html,hello", "javascript:alert(1)", ] { - assert!( - !allows_server_navigation(&server, &Url::parse(destination).unwrap()), - "{destination}" - ); - } - } - - #[test] - fn launcher_only_loads_bundled_pages() { - assert!(allows_launcher_navigation( - &Url::parse("tauri://localhost/index.html").unwrap() - )); - for destination in [ - SERVER_URL, - "https://localhost/", - "http://localhost:1420/", - "tauri://other/index.html", - "tauri://user@localhost/index.html", - ] { - assert!(!allows_launcher_navigation( + assert!(!allows_server_navigation( + &server, &Url::parse(destination).unwrap() )); } } #[test] - fn remote_pages_cannot_invoke_native_window_webview_or_path_commands() { + fn launcher_actions_are_restricted_to_the_bundled_origin() { + assert!(allows_launcher_navigation( + &Url::parse("tauri://localhost/action/open").unwrap() + )); + for input in [ + DEFAULT_SERVER, + "https://localhost/", + "tauri://other/index.html", + "tauri://user@localhost/index.html", + ] { + assert!(!allows_launcher_navigation(&Url::parse(input).unwrap())); + } + } + + #[test] + fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() { + let mut state = Connection::default(); + let first = state.begin_navigation(); + let second = state.begin_navigation(); + assert!(!state.timeout(first)); + state.ready = true; + assert!(state.timeout(second)); + assert!(state.ready); + assert!(!state.timeout(second)); + let third = state.begin_navigation(); + state.phase = "connected"; + assert!(!state.timeout(third)); + } + + #[test] + fn remote_pages_cannot_invoke_native_commands() { let app = tauri::test::mock_builder().build(context()).unwrap(); let window = WebviewWindowBuilder::new( &app, - "workspace", - WebviewUrl::External(Url::parse(SERVER_URL).unwrap()), + "workspace-1", + WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()), ) .build() .unwrap(); - for command in [ "plugin:window|close", "plugin:webview|create_webview_window", @@ -186,14 +436,17 @@ mod tests { cmd: command.into(), callback: tauri::ipc::CallbackFn(0), error: tauri::ipc::CallbackFn(1), - url: Url::parse(SERVER_URL).unwrap(), + url: Url::parse(DEFAULT_SERVER).unwrap(), body: tauri::ipc::InvokeBody::default(), headers: Default::default(), invoke_key: tauri::test::INVOKE_KEY.into(), }, ); - let error = response.expect_err("Remote native commands must be rejected"); - assert!(error.as_str().unwrap().contains("not allowed"), "{error}"); + assert!(response + .unwrap_err() + .as_str() + .unwrap() + .contains("not allowed")); } } @@ -201,14 +454,11 @@ mod tests { fn packaged_app_exposes_no_native_capabilities_or_global_api() { let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0"); assert_eq!(config["app"]["withGlobalTauri"], false); assert_eq!( config["app"]["security"]["capabilities"], serde_json::json!([]) ); - let capabilities = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("capabilities"); - assert!( - !capabilities.exists() || std::fs::read_dir(capabilities).unwrap().next().is_none() - ); } } diff --git a/native/src-tauri/src/locale.rs b/native/src-tauri/src/locale.rs new file mode 100644 index 0000000..831e604 --- /dev/null +++ b/native/src-tauri/src/locale.rs @@ -0,0 +1,5 @@ +pub mod en { + pub const APP_NAME: &str = "KanbanGit"; + pub const SERVER_SETTINGS: &str = "Server settings…"; + pub const EDIT: &str = "Edit"; +} diff --git a/native/src-tauri/src/server.rs b/native/src-tauri/src/server.rs new file mode 100644 index 0000000..5961765 --- /dev/null +++ b/native/src-tauri/src/server.rs @@ -0,0 +1,137 @@ +use std::{fs, io, io::Read, path::Path}; +use tauri::Url; + +pub const DEFAULT_SERVER: &str = "https://kanbangit.de/"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Server { + pub url: Url, + pub profile: [u8; 16], +} + +pub fn validate_url(input: &str) -> Result { + let input = input.trim(); + if !input + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) + || input + .chars() + .any(|character| character.is_control() || character == '\\') + { + return Err("invalidServer"); + } + let mut url = Url::parse(input).map_err(|_| "invalidServer")?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("invalidServer"); + } + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url) +} + +impl Server { + pub fn new(url: Url) -> io::Result { + let mut profile = [0; 16]; + fs::File::open("/dev/urandom")?.read_exact(&mut profile)?; + Ok(Self { url, profile }) + } + + pub fn load(path: &Path) -> io::Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "Invalid server configuration"); + let (url, profile) = contents.split_once('\n').ok_or_else(invalid)?; + let url = validate_url(url).map_err(|_| invalid())?; + if profile.len() != 32 || !profile.is_ascii() { + return Err(invalid()); + } + let mut bytes = [0; 16]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&profile[index * 2..index * 2 + 2], 16) + .map_err(|_| invalid())?; + } + Ok(Some(Self { + url, + profile: bytes, + })) + } + + pub fn save(&self, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let profile: String = self + .profile + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let temporary = path.with_extension("tmp"); + fs::write(&temporary, format!("{}\n{profile}", self.url))?; + fs::rename(temporary, path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_and_normalizes_installation_addresses() { + assert_eq!( + validate_url(" HTTPS://Example.com:443/kanban ") + .unwrap() + .as_str(), + "https://example.com/kanban/" + ); + assert!(validate_url("https://[::1]:8443/").is_ok()); + for input in [ + "http://example.com", + "https:example.com", + "https://user:secret@example.com", + "https://user@example.com", + "https://example.com/?token=secret", + "https://example.com/#secret", + "https://exa\nmple.com", + "https://example.com\\other", + "file:///tmp/test", + "javascript:alert(1)", + "", + ] { + assert!(validate_url(input).is_err(), "{input}"); + } + } + + #[test] + fn configuration_round_trip_preserves_only_the_address_and_profile() { + let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap(); + let path = + std::env::temp_dir().join(format!("kanbangit-server-{:x?}/active", server.profile)); + assert_eq!(Server::load(&path).unwrap(), None); + server.save(&path).unwrap(); + assert_eq!(Server::load(&path).unwrap(), Some(server.clone())); + let other = Server::new(validate_url("https://other.example.com:444/").unwrap()).unwrap(); + assert_ne!(server.profile, other.profile); + other.save(&path).unwrap(); + assert_eq!(Server::load(&path).unwrap(), Some(other)); + for contents in [ + "http://example.com/\n00000000000000000000000000000000", + "invalid", + "https://example.com/\nshort", + "https://example.com/\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + fs::write(&path, contents).unwrap(); + assert!(Server::load(&path).is_err()); + } + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } +} diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 94955fc..8d27a5f 100644 --- a/native/src-tauri/tauri.conf.json +++ b/native/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "targets": ["app"], "icon": ["icons/icon.png", "icons/icon.icns"], "macOS": { - "minimumSystemVersion": "11.0" + "minimumSystemVersion": "14.0" } } } diff --git a/native/src-tauri/tests/macos_smoke.rs b/native/src-tauri/tests/macos_smoke.rs index 81d2f6b..135a399 100644 --- a/native/src-tauri/tests/macos_smoke.rs +++ b/native/src-tauri/tests/macos_smoke.rs @@ -1,100 +1,144 @@ #[cfg(target_os = "macos")] fn main() { use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, }; use tauri::{webview::PageLoadEvent, Manager}; - let complete = Arc::new(AtomicBool::new(false)); let passed = Arc::new(AtomicBool::new(false)); - let navigation_complete = complete.clone(); - let navigation_passed = passed.clone(); + let stage = Arc::new(AtomicUsize::new(0)); + let page_stage = stage.clone(); + let result_passed = passed.clone(); let plugin = tauri::plugin::Builder::::new("macos-smoke") - .js_init_script("window.__nativeSmokeErrors = []; window.addEventListener('error', event => window.__nativeSmokeErrors.push(event.message || event.target.src), true);") - .on_page_load(|webview, payload| { + .js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);") + .on_page_load(move |webview, payload| { + if payload.event() != PageLoadEvent::Finished { return; } if webview.label() == "launcher" { webview.eval(r#" (async () => { - for (let attempt = 0; attempt < 100; attempt++) { - if (document.querySelector('.open-workspace')?.textContent === 'Open workspace') break; - await new Promise(resolve => setTimeout(resolve, 100)); - } - const link = document.querySelector('.open-workspace'); - const appearance = document.querySelector('#appearance'); - const translated = link?.textContent === 'Open workspace'; - appearance.value = 'dark'; - appearance.dispatchEvent(new Event('change')); - const dark = getComputedStyle(document.documentElement).backgroundColor === 'rgb(15, 23, 42)'; - appearance.value = 'light'; - appearance.dispatchEvent(new Event('change')); - const light = getComputedStyle(document.documentElement).backgroundColor === 'rgb(247, 249, 251)'; - appearance.value = 'system'; - appearance.dispatchEvent(new Event('change')); - if (translated && dark && light) { - link.click(); - } else { - location.href = 'tauri://localhost/__native_smoke_result?passed=false&stage=launcher&translated=' + translated + '&dark=' + dark + '&light=' + light + '&background=' + encodeURIComponent(getComputedStyle(document.documentElement).backgroundColor) + '&label=' + encodeURIComponent(link?.textContent) + '&errors=' + encodeURIComponent(JSON.stringify(window.__nativeSmokeErrors)); - } - })(); - "#).unwrap(); - } else if webview.label() == "workspace" { - webview.eval(r#" - (async () => { - for (let attempt = 0; attempt < 100; attempt++) { - if (document.querySelector('#app')?.innerText.trim().length > 100) break; - await new Promise(resolve => setTimeout(resolve, 100)); - } - const root = document.querySelector('#app'); - const rendered = root?.innerText.trim().length > 100 && root.getBoundingClientRect().height > 0; - let denied = true; - for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) { - try { - await window.__TAURI_INTERNALS__.invoke(cmd); - denied = false; - } catch (error) { - denied = denied && String(error).includes('not allowed'); + const wait = async predicate => { + for (let attempt = 0; attempt < 220; attempt++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 100)); } + throw new Error('Timed out waiting for local state'); + }; + try { + await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace'); + const input = document.querySelector('#server-url'); + const form = document.querySelector('#server-form'); + const status = document.querySelector('#connection-status'); + input.value = 'http://example.com'; + form.requestSubmit(); + if (input.getAttribute('aria-invalid') !== 'true' || !status.textContent.includes('HTTPS')) throw new Error('Invalid address accepted'); + for (const [theme, expected] of [['dark', 'rgb(15, 23, 42)'], ['light', 'rgb(247, 249, 251)']]) { + const appearance = document.querySelector('#appearance'); + appearance.value = theme; + appearance.dispatchEvent(new Event('change')); + if (getComputedStyle(document.documentElement).backgroundColor !== expected) throw new Error('Theme failed'); + } + input.value = 'https://127.0.0.1:9/'; + form.requestSubmit(); + await wait(() => status.dataset.state === 'connectionError'); + if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed'); + input.value = 'https://kanbangit.de/'; + form.requestSubmit(); + if (document.querySelector('#switch-confirmation').hidden) throw new Error('Missing switch confirmation'); + document.querySelector('#cancel-switch').click(); + if (!document.querySelector('#switch-confirmation').hidden) throw new Error('Cancel failed'); + form.requestSubmit(); + document.querySelector('#confirm-switch').click(); + } catch (error) { + location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message); } - const noGlobalApi = typeof window.__TAURI__ === 'undefined'; - const report = new URL('/__native_smoke_result', location.origin); - report.searchParams.set('passed', String(rendered && denied && noGlobalApi)); - report.searchParams.set('rendered', String(rendered)); - report.searchParams.set('nativeDenied', String(denied)); - report.searchParams.set('globalAbsent', String(noGlobalApi)); - location.href = report.href; })(); "#).unwrap(); + } else if webview.label().starts_with("workspace-") && payload.url().host_str() == Some("kanbangit.de") { + let current_stage = page_stage.fetch_add(1, Ordering::SeqCst); + if current_stage == 0 { + webview.eval(r#" + (async () => { + for (let attempt = 0; attempt < 100; attempt++) { + if (document.querySelector('#app')?.innerText.trim().length > 100) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + const rendered = document.querySelector('#app')?.innerText.trim().length > 100; + let denied = true; + for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) { + try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; } + catch (error) { denied = denied && String(error).includes('not allowed'); } + } + const input = document.createElement('input'); + input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); + document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; + localStorage.setItem('kan52_fixture', 'isolated'); + const ok = rendered && denied && typeof window.__TAURI__ === 'undefined'; + location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; + })(); + "#).unwrap(); + } else { + webview.eval(r#" + const isolated = !document.cookie.includes('kan52_fixture') && localStorage.getItem('kan52_fixture') === null; + location.href = '/__native_smoke_result?stage=isolated&passed=' + isolated; + "#).unwrap(); + } } }) .on_navigation(move |webview, url| { + if url.path() != "/__native_smoke_result" { return true; } let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true"); + let loaded = url.query_pairs().any(|(key, value)| key == "stage" && value == "loaded"); println!("macOS WebView smoke test: {}", url.query().unwrap_or_default()); - navigation_passed.store(success, Ordering::SeqCst); - navigation_complete.store(true, Ordering::SeqCst); - webview.app_handle().exit(if success { 0 } else { 1 }); + if success && loaded { + let app = webview.app_handle().clone(); + let label = webview.label().to_string(); + std::thread::spawn(move || { + let window = app.get_webview_window(&label).unwrap(); + // A blocked cross-origin navigation must leave the existing input intact. + window.navigate(tauri::Url::parse("https://kanbangit.de:9/unreachable").unwrap()).unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + window.eval(r#" + if (document.querySelector('#kan52-unsaved')?.value !== 'unsaved input') { + location.href = '/__native_smoke_result?passed=false&stage=preservation'; + } + "#).unwrap(); + let launcher = app.get_webview_window("launcher").unwrap(); + launcher.show().unwrap(); + launcher.eval(r#" + document.querySelector('#server-url').value = 'https://kanbangit.de/login/'; + document.querySelector('#server-form').requestSubmit(); + document.querySelector('#confirm-switch').click(); + "#).unwrap(); + }); + } else { + result_passed.store(success, Ordering::SeqCst); + webview.app_handle().exit(if success { 0 } else { 1 }); + } false }) .build(); + let mut context = kanbangit_native::context(); + context.config_mut().identifier = format!("de.kanbangit.smoke.kan52.{}", std::process::id()); let app = kanbangit_native::builder() .plugin(plugin) - .build(kanbangit_native::context()) + .build(context) .expect("The macOS application must start"); + let config_dir = app.path().app_config_dir().unwrap(); let handle = app.handle().clone(); std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); - if !complete.load(Ordering::SeqCst) { - eprintln!("macOS smoke test timed out before the server UI rendered"); - handle.exit(1); - } + std::thread::sleep(std::time::Duration::from_secs(60)); + eprintln!("macOS smoke test timed out"); + handle.exit(1); }); app.run_return(|_, _| {}); + let _ = std::fs::remove_dir_all(config_dir); assert!( passed.load(Ordering::SeqCst), "The native smoke test must complete successfully" diff --git a/native/tests/launcher-browser.mjs b/native/tests/launcher-browser.mjs new file mode 100644 index 0000000..7bf7655 --- /dev/null +++ b/native/tests/launcher-browser.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import process from 'node:process'; + +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const actions = []; +const server = createServer(async (request, response) => { + const url = new URL(request.url, 'http://localhost'); + if (url.pathname.startsWith('/action/')) { + actions.push(url); + response.writeHead(204).end(); + return; + } + const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); + if ( + ![ + 'index.html', + 'main.js', + 'server.js', + 'locales/en.js', + 'styles.css', + 'mark.svg', + ].includes(file) + ) { + response.writeHead(404).end(); + return; + } + response.setHeader( + 'Content-Type', + file.endsWith('.js') + ? 'text/javascript' + : file.endsWith('.css') + ? 'text/css' + : file.endsWith('.svg') + ? 'image/svg+xml' + : 'text/html', + ); + response.end(await readFile(new URL(`../ui/${file}`, import.meta.url))); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const page = await browser.newPage(); +const state = (phase, ready = false, server = 'https://kanbangit.de/') => + page.evaluate( + ({ phase, ready, server }) => { + window.updateLauncher( + new URLSearchParams({ + phase, + ready: String(ready), + server, + }).toString(), + ); + }, + { phase, ready, server }, + ); +const latestAction = async (name) => { + await page.waitForTimeout(100); + assert.equal(actions.at(-1)?.pathname, `/action/${name}`); +}; +try { + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await state('idle'); + for (const width of [320, 390, 1440]) { + await page.setViewportSize({ width, height: 900 }); + for (const theme of ['light', 'dark', 'system']) { + await page.getByLabel('Appearance').selectOption(theme); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + true, + ); + assert.equal( + await page + .getByRole('button', { + name: 'Open workspace', + exact: true, + }) + .isVisible(), + true, + ); + } + } + await page.getByLabel('Server address').fill('http://example.com/'); + await page.getByLabel('Server address').press('Enter'); + assert.equal( + await page.getByLabel('Server address').getAttribute('aria-invalid'), + 'true', + ); + await page.getByLabel('Server address').fill('https://first.example.com/'); + await page.getByLabel('Server address').press('Enter'); + await latestAction('open'); + assert.equal( + actions.at(-1).searchParams.get('server'), + 'https://first.example.com/', + ); + await state('connecting', false, 'https://first.example.com/'); + assert.equal( + await page + .getByRole('button', { name: 'Open workspace', exact: true }) + .isDisabled(), + true, + ); + await state('connectionError', false, 'https://first.example.com/'); + assert.equal( + await page.getByLabel('Server address').inputValue(), + 'https://first.example.com/', + ); + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await latestAction('open'); + await state('connected', true, 'https://first.example.com/'); + await page + .getByRole('button', { name: 'Return to workspace', exact: true }) + .click(); + await latestAction('resume'); + await page + .getByLabel('Server address') + .fill('https://second.example.com/app'); + await page + .getByRole('button', { name: 'Open workspace', exact: true }) + .click(); + assert.equal( + await page + .getByRole('button', { name: 'Switch server', exact: true }) + .isVisible(), + true, + ); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await latestAction('resume'); + await page.getByLabel('Server address').press('Enter'); + await page + .getByRole('button', { name: 'Switch server', exact: true }) + .click(); + await latestAction('open'); + assert.equal( + actions.at(-1).searchParams.get('server'), + 'https://second.example.com/app/', + ); + console.log( + 'Launcher browser tests passed: responsive themes, keyboard, validation, offline retry, resume and confirmed switching.', + ); +} finally { + await browser.close(); + server.close(); +} diff --git a/native/tests/launcher.test.js b/native/tests/launcher.test.js index 0a01540..16e5adf 100644 --- a/native/tests/launcher.test.js +++ b/native/tests/launcher.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { en } from '../ui/locales/en.js'; +import { normalizeServer, needsConfirmation } from '../ui/server.js'; const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8'); @@ -12,13 +13,41 @@ test('every launcher text resolves to a nonempty English translation', () => { } }); -test('the launcher opens only the configured HTTPS workspace without an IPC bridge', () => { - const links = [...html.matchAll(/]*href="([^"]+)"/g)]; - assert.equal(links.length, 1); - assert.equal(links[0][1], 'https://kanbangit.de/'); - const main = readFileSync( - new URL('../ui/main.js', import.meta.url), - 'utf8', +test('installation addresses support custom domains, ports and base paths', () => { + assert.equal( + normalizeServer(' HTTPS://Example.com:443/kanban '), + 'https://example.com/kanban/', + ); + assert.equal(normalizeServer('https://[::1]:8443/'), 'https://[::1]:8443/'); + for (const input of [ + 'http://example.com', + 'https:example.com', + 'https://user:secret@example.com', + 'https://user@example.com', + 'https://example.com/?token=secret', + 'https://example.com/#secret', + 'https://exa\nmple.com', + 'https://example.com\\other', + 'file:///tmp/test', + 'javascript:alert(1)', + '', + ]) { + assert.throws(() => normalizeServer(input), undefined, input); + } +}); + +test('changing the installation requires confirmation, retrying does not', () => { + assert.equal(needsConfirmation(null, 'https://example.com/'), false); + assert.equal( + needsConfirmation('https://example.com/', 'https://example.com/'), + false, + ); + assert.equal( + needsConfirmation('https://example.com/', 'https://example.com:8443/'), + true, + ); + assert.equal( + needsConfirmation('https://example.com/a/', 'https://example.com/b/'), + true, ); - assert.doesNotMatch(main, /__TAURI__|invoke\(/); }); diff --git a/native/ui/index.html b/native/ui/index.html index 26a4ab4..e7c2d31 100644 --- a/native/ui/index.html +++ b/native/ui/index.html @@ -14,8 +14,19 @@

- -

kanbangit.de

+
+ + +

+ +
+

+ +