KAN-51: add isolated Tauri macOS app scaffold

This commit is contained in:
2026-09-26 18:41:04 +00:00
parent 7e19364757
commit 2a56d03dbf
21 changed files with 5267 additions and 1 deletions
+214
View File
@@ -0,0 +1,214 @@
use tauri::{
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
const SERVER_URL: &str = "https://kanbangit.de/";
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
destination.scheme() == "https"
&& destination.origin() == server.origin()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost")
&& destination.port().is_none()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn show_launcher(app: &AppHandle) {
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()?;
let handle = app.clone();
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) {
show_launcher(&handle);
}
});
Ok(())
}
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
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)
.build()?;
Ok(())
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_allows_application_routes_on_the_same_https_origin() {
let server = Url::parse(SERVER_URL).unwrap();
for path in [
"/",
"/login",
"/two-factor-challenge",
"/projects/1?ticket=51#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/",
"https://example.com/",
"https://user:password@kanbangit.de/",
"https://user@kanbangit.de/",
"tauri://localhost/index.html",
"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(
&Url::parse(destination).unwrap()
));
}
}
#[test]
fn remote_pages_cannot_invoke_native_window_webview_or_path_commands() {
let app = tauri::test::mock_builder().build(context()).unwrap();
let window = WebviewWindowBuilder::new(
&app,
"workspace",
WebviewUrl::External(Url::parse(SERVER_URL).unwrap()),
)
.build()
.unwrap();
for command in [
"plugin:window|close",
"plugin:webview|create_webview_window",
"plugin:path|resolve_directory",
] {
let response = tauri::test::get_ipc_response(
&window,
tauri::webview::InvokeRequest {
cmd: command.into(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: Url::parse(SERVER_URL).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}");
}
}
#[test]
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["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()
);
}
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
kanbangit_native::run();
}