Merge pull request 'KAN-52: Add server selection, isolated sessions and connection recovery' (#148) from feature/KAN-52-server-sessions-connection-errors into main
Reviewed-on: #148
This commit was merged in pull request #148.
This commit is contained in:
+351
-101
@@ -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::{
|
use tauri::{
|
||||||
|
menu::{MenuBuilder, SubmenuBuilder},
|
||||||
webview::{NewWindowResponse, PageLoadEvent},
|
webview::{NewWindowResponse, PageLoadEvent},
|
||||||
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_URL: &str = "https://kanbangit.de/";
|
#[derive(Default)]
|
||||||
|
struct Connection {
|
||||||
|
server: Option<Server>,
|
||||||
|
label: Option<String>,
|
||||||
|
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 {
|
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
|
||||||
destination.scheme() == "https"
|
destination.scheme() == "https"
|
||||||
@@ -20,81 +50,298 @@ fn allows_launcher_navigation(destination: &Url) -> bool {
|
|||||||
&& destination.password().is_none()
|
&& destination.password().is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
|
||||||
|
Ok(app.path().app_config_dir()?.join("active-server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_launcher(app: &AppHandle) {
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
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) {
|
fn show_launcher(app: &AppHandle) {
|
||||||
|
update_launcher(app);
|
||||||
if let Some(window) = app.get_webview_window("launcher") {
|
if let Some(window) = app.get_webview_window("launcher") {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_workspace(app: &AppHandle) -> tauri::Result<()> {
|
fn begin_navigation(app: &AppHandle, label: &str) {
|
||||||
if let Some(window) = app.get_webview_window("workspace") {
|
let state = app.state::<Mutex<Connection>>();
|
||||||
window.show()?;
|
let revision = {
|
||||||
window.set_focus()?;
|
let mut state = state.lock().unwrap();
|
||||||
return Ok(());
|
if state.label.as_deref() != Some(label) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
let server = Url::parse(SERVER_URL).expect("The built-in server URL must be valid");
|
state.begin_navigation()
|
||||||
let window = WebviewWindowBuilder::new(app, "workspace", WebviewUrl::External(server.clone()))
|
};
|
||||||
.title("KanbanGit")
|
update_launcher(app);
|
||||||
.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()?;
|
|
||||||
|
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
window.on_window_event(move |event| {
|
std::thread::spawn(move || {
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
std::thread::sleep(Duration::from_secs(15));
|
||||||
|
let state = handle.state::<Mutex<Connection>>();
|
||||||
|
let timed_out = state.lock().unwrap().timeout(revision);
|
||||||
|
if timed_out {
|
||||||
show_launcher(&handle);
|
show_launcher(&handle);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||||
|
let url = validate_url(input)?;
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
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::<Mutex<Connection>>();
|
||||||
|
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::<Mutex<Connection>>();
|
||||||
|
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(())
|
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::<Mutex<Connection>>()
|
||||||
|
.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::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||||
|
show_launcher(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||||
tauri::generate_context!()
|
tauri::generate_context!()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
tauri::Builder::default().setup(|app| {
|
tauri::Builder::default().setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||||
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
|
Ok(server) => (server, "idle"),
|
||||||
.title("KanbanGit")
|
Err(_) => (None, "storageError"),
|
||||||
.inner_size(560.0, 520.0)
|
};
|
||||||
.min_inner_size(375.0, 480.0)
|
app.manage(Mutex::new(Connection {
|
||||||
.center()
|
server,
|
||||||
.on_navigation(move |destination| {
|
phase,
|
||||||
if allows_launcher_navigation(destination) {
|
..Default::default()
|
||||||
return true;
|
}));
|
||||||
}
|
let menu = MenuBuilder::new(app)
|
||||||
if destination.as_str() == SERVER_URL {
|
.item(
|
||||||
let handle = handle.clone();
|
&SubmenuBuilder::new(app, en::APP_NAME)
|
||||||
// Defer window creation until the navigation callback has returned.
|
.text("server-settings", en::SERVER_SETTINGS)
|
||||||
let _ = handle.clone().run_on_main_thread(move || {
|
.separator()
|
||||||
if let Err(error) = open_workspace(&handle) {
|
.quit()
|
||||||
eprintln!("Could not open the workspace: {error}");
|
.build()?,
|
||||||
show_launcher(&handle);
|
)
|
||||||
}
|
.item(
|
||||||
});
|
&SubmenuBuilder::new(app, en::EDIT)
|
||||||
}
|
.undo()
|
||||||
false
|
.redo()
|
||||||
})
|
.separator()
|
||||||
.on_new_window(|_, _| NewWindowResponse::Deny)
|
.cut()
|
||||||
|
.copy()
|
||||||
|
.paste()
|
||||||
|
.select_all()
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
.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::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.server
|
||||||
|
.clone();
|
||||||
|
if let Some(server) = server {
|
||||||
|
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
||||||
|
{
|
||||||
|
handle.state::<Mutex<Connection>>().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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -109,72 +356,75 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_allows_application_routes_on_the_same_https_origin() {
|
fn navigation_is_limited_to_the_selected_https_origin() {
|
||||||
let server = Url::parse(SERVER_URL).unwrap();
|
let server = validate_url("https://example.com:8443/app/").unwrap();
|
||||||
for path in [
|
for path in [
|
||||||
"/",
|
|
||||||
"/login",
|
"/login",
|
||||||
"/two-factor-challenge",
|
"/two-factor-challenge",
|
||||||
"/projects/1?ticket=51#details",
|
"/projects/1?ticket=52#details",
|
||||||
] {
|
] {
|
||||||
assert!(allows_server_navigation(
|
assert!(allows_server_navigation(
|
||||||
&server,
|
&server,
|
||||||
&server.join(path).unwrap()
|
&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 [
|
for destination in [
|
||||||
"http://kanbangit.de/",
|
"http://example.com:8443/",
|
||||||
"https://kanbangit.de:444/",
|
|
||||||
"https://kanbangit.de.example.com/",
|
|
||||||
"https://example.com/",
|
"https://example.com/",
|
||||||
"https://user:password@kanbangit.de/",
|
"https://other.example.com:8443/",
|
||||||
"https://user@kanbangit.de/",
|
"https://user:secret@example.com:8443/",
|
||||||
"tauri://localhost/index.html",
|
"tauri://localhost/action/open?server=https://evil.test",
|
||||||
"file:///etc/passwd",
|
"file:///etc/passwd",
|
||||||
"data:text/html,hello",
|
"data:text/html,hello",
|
||||||
"javascript:alert(1)",
|
"javascript:alert(1)",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(!allows_server_navigation(
|
||||||
!allows_server_navigation(&server, &Url::parse(destination).unwrap()),
|
&server,
|
||||||
"{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(
|
|
||||||
&Url::parse(destination).unwrap()
|
&Url::parse(destination).unwrap()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 app = tauri::test::mock_builder().build(context()).unwrap();
|
||||||
let window = WebviewWindowBuilder::new(
|
let window = WebviewWindowBuilder::new(
|
||||||
&app,
|
&app,
|
||||||
"workspace",
|
"workspace-1",
|
||||||
WebviewUrl::External(Url::parse(SERVER_URL).unwrap()),
|
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
for command in [
|
for command in [
|
||||||
"plugin:window|close",
|
"plugin:window|close",
|
||||||
"plugin:webview|create_webview_window",
|
"plugin:webview|create_webview_window",
|
||||||
@@ -186,14 +436,17 @@ mod tests {
|
|||||||
cmd: command.into(),
|
cmd: command.into(),
|
||||||
callback: tauri::ipc::CallbackFn(0),
|
callback: tauri::ipc::CallbackFn(0),
|
||||||
error: tauri::ipc::CallbackFn(1),
|
error: tauri::ipc::CallbackFn(1),
|
||||||
url: Url::parse(SERVER_URL).unwrap(),
|
url: Url::parse(DEFAULT_SERVER).unwrap(),
|
||||||
body: tauri::ipc::InvokeBody::default(),
|
body: tauri::ipc::InvokeBody::default(),
|
||||||
headers: Default::default(),
|
headers: Default::default(),
|
||||||
invoke_key: tauri::test::INVOKE_KEY.into(),
|
invoke_key: tauri::test::INVOKE_KEY.into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let error = response.expect_err("Remote native commands must be rejected");
|
assert!(response
|
||||||
assert!(error.as_str().unwrap().contains("not allowed"), "{error}");
|
.unwrap_err()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("not allowed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,14 +454,11 @@ mod tests {
|
|||||||
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
|
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
|
||||||
let config: serde_json::Value =
|
let config: serde_json::Value =
|
||||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
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"]["withGlobalTauri"], false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config["app"]["security"]["capabilities"],
|
config["app"]["security"]["capabilities"],
|
||||||
serde_json::json!([])
|
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()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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<Url, &'static str> {
|
||||||
|
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<Self> {
|
||||||
|
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<Option<Self>> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"targets": ["app"],
|
"targets": ["app"],
|
||||||
"icon": ["icons/icon.png", "icons/icon.icns"],
|
"icon": ["icons/icon.png", "icons/icon.icns"],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "11.0"
|
"minimumSystemVersion": "14.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,144 @@
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn main() {
|
fn main() {
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
};
|
};
|
||||||
use tauri::{webview::PageLoadEvent, Manager};
|
use tauri::{webview::PageLoadEvent, Manager};
|
||||||
|
|
||||||
let complete = Arc::new(AtomicBool::new(false));
|
|
||||||
let passed = Arc::new(AtomicBool::new(false));
|
let passed = Arc::new(AtomicBool::new(false));
|
||||||
let navigation_complete = complete.clone();
|
let stage = Arc::new(AtomicUsize::new(0));
|
||||||
let navigation_passed = passed.clone();
|
let page_stage = stage.clone();
|
||||||
|
let result_passed = passed.clone();
|
||||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||||
.js_init_script("window.__nativeSmokeErrors = []; window.addEventListener('error', event => window.__nativeSmokeErrors.push(event.message || event.target.src), true);")
|
.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(|webview, payload| {
|
.on_page_load(move |webview, payload| {
|
||||||
|
|
||||||
if payload.event() != PageLoadEvent::Finished {
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if webview.label() == "launcher" {
|
if webview.label() == "launcher" {
|
||||||
webview.eval(r#"
|
webview.eval(r#"
|
||||||
(async () => {
|
(async () => {
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
const wait = async predicate => {
|
||||||
if (document.querySelector('.open-workspace')?.textContent === 'Open workspace') break;
|
for (let attempt = 0; attempt < 220; attempt++) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
if (predicate()) return;
|
||||||
}
|
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');
|
|
||||||
}
|
}
|
||||||
|
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();
|
"#).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| {
|
.on_navigation(move |webview, url| {
|
||||||
|
|
||||||
if url.path() != "/__native_smoke_result" {
|
if url.path() != "/__native_smoke_result" {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "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());
|
println!("macOS WebView smoke test: {}", url.query().unwrap_or_default());
|
||||||
navigation_passed.store(success, Ordering::SeqCst);
|
if success && loaded {
|
||||||
navigation_complete.store(true, Ordering::SeqCst);
|
let app = webview.app_handle().clone();
|
||||||
webview.app_handle().exit(if success { 0 } else { 1 });
|
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
|
false
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
let mut context = kanbangit_native::context();
|
||||||
|
context.config_mut().identifier = format!("de.kanbangit.smoke.kan52.{}", std::process::id());
|
||||||
let app = kanbangit_native::builder()
|
let app = kanbangit_native::builder()
|
||||||
.plugin(plugin)
|
.plugin(plugin)
|
||||||
.build(kanbangit_native::context())
|
.build(context)
|
||||||
.expect("The macOS application must start");
|
.expect("The macOS application must start");
|
||||||
|
let config_dir = app.path().app_config_dir().unwrap();
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||||
if !complete.load(Ordering::SeqCst) {
|
eprintln!("macOS smoke test timed out");
|
||||||
eprintln!("macOS smoke test timed out before the server UI rendered");
|
handle.exit(1);
|
||||||
handle.exit(1);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
app.run_return(|_, _| {});
|
app.run_return(|_, _| {});
|
||||||
|
let _ = std::fs::remove_dir_all(config_dir);
|
||||||
assert!(
|
assert!(
|
||||||
passed.load(Ordering::SeqCst),
|
passed.load(Ordering::SeqCst),
|
||||||
"The native smoke test must complete successfully"
|
"The native smoke test must complete successfully"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { en } from '../ui/locales/en.js';
|
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');
|
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', () => {
|
test('installation addresses support custom domains, ports and base paths', () => {
|
||||||
const links = [...html.matchAll(/<a\s[^>]*href="([^"]+)"/g)];
|
assert.equal(
|
||||||
assert.equal(links.length, 1);
|
normalizeServer(' HTTPS://Example.com:443/kanban '),
|
||||||
assert.equal(links[0][1], 'https://kanbangit.de/');
|
'https://example.com/kanban/',
|
||||||
const main = readFileSync(
|
);
|
||||||
new URL('../ui/main.js', import.meta.url),
|
assert.equal(normalizeServer('https://[::1]:8443/'), 'https://[::1]:8443/');
|
||||||
'utf8',
|
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\(/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-2
@@ -14,8 +14,19 @@
|
|||||||
<p class="eyebrow" data-i18n="appName"></p>
|
<p class="eyebrow" data-i18n="appName"></p>
|
||||||
<h1 data-i18n="title"></h1>
|
<h1 data-i18n="title"></h1>
|
||||||
<p class="description" data-i18n="description"></p>
|
<p class="description" data-i18n="description"></p>
|
||||||
<a class="open-workspace" href="https://kanbangit.de/" data-i18n="openWorkspace"></a>
|
<form id="server-form" novalidate>
|
||||||
<p class="server">kanbangit.de</p>
|
<label for="server-url" data-i18n="serverLabel"></label>
|
||||||
|
<input id="server-url" type="url" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="server-help connection-status" value="https://kanbangit.de/" required />
|
||||||
|
<p id="server-help" class="description" data-i18n="serverHelp"></p>
|
||||||
|
<button class="open-workspace" type="submit" data-i18n="openWorkspace"></button>
|
||||||
|
</form>
|
||||||
|
<p id="connection-status" class="description" role="status" aria-live="polite"></p>
|
||||||
|
<div id="switch-confirmation" hidden>
|
||||||
|
<p class="description" data-i18n="switchWarning"></p>
|
||||||
|
<button id="confirm-switch" type="button" data-i18n="confirmSwitch"></button>
|
||||||
|
<button id="cancel-switch" type="button" data-i18n="cancel"></button>
|
||||||
|
</div>
|
||||||
|
<button id="resume" type="button" data-i18n="resume" hidden></button>
|
||||||
<label class="appearance">
|
<label class="appearance">
|
||||||
<span data-i18n="appearance"></span>
|
<span data-i18n="appearance"></span>
|
||||||
<select id="appearance">
|
<select id="appearance">
|
||||||
|
|||||||
+21
-2
@@ -1,8 +1,27 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
appName: 'KanbanGit',
|
appName: 'KanbanGit',
|
||||||
title: 'Your workspace, one click away.',
|
title: 'Choose your workspace.',
|
||||||
description: 'Open your projects and keep work moving.',
|
description: 'Connect to your KanbanGit installation over HTTPS.',
|
||||||
|
serverLabel: 'Server address',
|
||||||
|
serverHelp:
|
||||||
|
'Use the installation address without login details, a query or a fragment.',
|
||||||
openWorkspace: 'Open workspace',
|
openWorkspace: 'Open workspace',
|
||||||
|
retry: 'Try again',
|
||||||
|
resume: 'Return to workspace',
|
||||||
|
switchWarning:
|
||||||
|
'Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.',
|
||||||
|
confirmSwitch: 'Switch server',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
idle: '',
|
||||||
|
connecting: 'Connecting to your server…',
|
||||||
|
connected:
|
||||||
|
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
|
||||||
|
connectionError:
|
||||||
|
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
|
||||||
|
invalidServer:
|
||||||
|
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
|
||||||
|
storageError:
|
||||||
|
'The server setting could not be read or saved. Check your local storage permissions and try again.',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
system: 'System',
|
system: 'System',
|
||||||
light: 'Light',
|
light: 'Light',
|
||||||
|
|||||||
@@ -1,9 +1,87 @@
|
|||||||
import { en } from './locales/en.js';
|
import { en } from './locales/en.js';
|
||||||
|
import { normalizeServer, needsConfirmation } from './server.js';
|
||||||
|
|
||||||
for (const element of document.querySelectorAll('[data-i18n]')) {
|
for (const element of document.querySelectorAll('[data-i18n]')) {
|
||||||
element.textContent = en[element.dataset.i18n];
|
element.textContent = en[element.dataset.i18n];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const form = document.querySelector('#server-form');
|
||||||
|
const input = document.querySelector('#server-url');
|
||||||
|
const status = document.querySelector('#connection-status');
|
||||||
|
const open = document.querySelector('.open-workspace');
|
||||||
|
const resume = document.querySelector('#resume');
|
||||||
|
const confirmation = document.querySelector('#switch-confirmation');
|
||||||
|
let activeServer = null;
|
||||||
|
let pendingServer = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function action(name, server) {
|
||||||
|
const url = new URL(`/action/${name}`, location.href);
|
||||||
|
if (server) {
|
||||||
|
url.searchParams.set('server', server);
|
||||||
|
}
|
||||||
|
location.href = url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateLauncher = (query) => {
|
||||||
|
const state = new URLSearchParams(query);
|
||||||
|
const phase = state.get('phase');
|
||||||
|
const server = state.get('server');
|
||||||
|
if (!initialized) {
|
||||||
|
input.value = server;
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
if (phase === 'connecting' || phase === 'connected') {
|
||||||
|
activeServer = server;
|
||||||
|
}
|
||||||
|
status.textContent = en[phase] ?? '';
|
||||||
|
status.dataset.state = phase;
|
||||||
|
open.disabled = phase === 'connecting';
|
||||||
|
open.textContent =
|
||||||
|
en[phase === 'connectionError' ? 'retry' : 'openWorkspace'];
|
||||||
|
resume.hidden = state.get('ready') !== 'true';
|
||||||
|
form.setAttribute('aria-busy', String(phase === 'connecting'));
|
||||||
|
};
|
||||||
|
|
||||||
|
function connect(server) {
|
||||||
|
confirmation.hidden = true;
|
||||||
|
pendingServer = null;
|
||||||
|
action('open', server);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
let server;
|
||||||
|
try {
|
||||||
|
server = normalizeServer(input.value);
|
||||||
|
} catch {
|
||||||
|
status.textContent = en.invalidServer;
|
||||||
|
input.setAttribute('aria-invalid', 'true');
|
||||||
|
input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.removeAttribute('aria-invalid');
|
||||||
|
if (needsConfirmation(activeServer, server)) {
|
||||||
|
pendingServer = server;
|
||||||
|
confirmation.hidden = false;
|
||||||
|
document.querySelector('#confirm-switch').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#confirm-switch').addEventListener('click', () => {
|
||||||
|
if (pendingServer) {
|
||||||
|
connect(pendingServer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector('#cancel-switch').addEventListener('click', () => {
|
||||||
|
confirmation.hidden = true;
|
||||||
|
pendingServer = null;
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
resume.addEventListener('click', () => action('resume'));
|
||||||
|
|
||||||
document.querySelector('#appearance').addEventListener('change', (event) => {
|
document.querySelector('#appearance').addEventListener('change', (event) => {
|
||||||
document.documentElement.dataset.appearance = event.target.value;
|
document.documentElement.dataset.appearance = event.target.value;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export function normalizeServer(input) {
|
||||||
|
const value = input.trim();
|
||||||
|
if (
|
||||||
|
!/^https:\/\//i.test(value) ||
|
||||||
|
[...value].some(
|
||||||
|
(character) =>
|
||||||
|
character.charCodeAt(0) < 32 ||
|
||||||
|
character.charCodeAt(0) === 127 ||
|
||||||
|
character === '\\',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('invalidServer');
|
||||||
|
}
|
||||||
|
const url = new URL(value);
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
!url.hostname ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
value.includes('?') ||
|
||||||
|
value.includes('#')
|
||||||
|
) {
|
||||||
|
throw new Error('invalidServer');
|
||||||
|
}
|
||||||
|
if (!url.pathname.endsWith('/')) {
|
||||||
|
url.pathname += '/';
|
||||||
|
}
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsConfirmation(activeServer, nextServer) {
|
||||||
|
return activeServer !== null && activeServer !== nextServer;
|
||||||
|
}
|
||||||
@@ -125,3 +125,56 @@ select {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--control);
|
background: var(--control);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--control);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-workspace {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#switch-confirmation {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,16 +68,14 @@ try {
|
|||||||
]) {
|
]) {
|
||||||
await open(path);
|
await open(path);
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('radio', {
|
||||||
name: theme === 'light' ? 'Light' : 'Dark',
|
name: theme === 'light' ? /^Light / : /^Dark /,
|
||||||
exact: true,
|
|
||||||
})
|
})
|
||||||
.click();
|
.press('Space');
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('radio', {
|
||||||
name: theme === 'light' ? 'Light' : 'Dark',
|
name: theme === 'light' ? /^Light / : /^Dark /,
|
||||||
exact: true,
|
checked: true,
|
||||||
pressed: true,
|
|
||||||
})
|
})
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -118,7 +116,7 @@ try {
|
|||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 390, height: 480 });
|
await page.setViewportSize({ width: 390, height: 480 });
|
||||||
await open('/login');
|
await open('/login');
|
||||||
await page.getByRole('button', { name: 'System', exact: true }).click();
|
await page.getByRole('radio', { name: /^System / }).press('Space');
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.documentElement.classList.contains('dark'),
|
() => !document.documentElement.classList.contains('dark'),
|
||||||
|
|||||||
Reference in New Issue
Block a user