Files
KanbanGit/native/src-tauri/src/desktop.rs
T
Twyco 4afaa79834
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
CI/CD / test_and_check_app (pull_request) Successful in 6m44s
CI/CD / Publish immutable image (pull_request) Skipped
fix(KAN-71): correct Apple navigation gestures and scroll bounds
2026-09-29 07:22:45 +02:00

611 lines
21 KiB
Rust

use crate::locale::en;
use crate::server::{validate_url, Server, DEFAULT_SERVER};
use std::{path::PathBuf, sync::Mutex, time::Duration};
use tauri::{
menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder},
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
#[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 {
destination.scheme() == "https"
&& destination.origin() == server.origin()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn allows_external_navigation(destination: &Url) -> bool {
matches!(destination.scheme(), "https" | "http")
&& destination.host_str().is_some()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn open_external(destination: &Url) {
if allows_external_navigation(destination) {
// Arguments are passed directly to Launch Services, never through a shell.
let destination = destination.to_string();
std::thread::spawn(move || {
let _ = std::process::Command::new("/usr/bin/open")
.arg("--")
.arg(destination)
.status();
});
}
}
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 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) {
update_launcher(app);
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.show();
let _ = window.set_focus();
}
}
fn begin_navigation(app: &AppHandle, label: &str) {
let state = app.state::<Mutex<Connection>>();
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();
std::thread::spawn(move || {
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);
}
});
}
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 popup_server = server_url.clone();
let popup_handle = app.clone();
let popup_label = label.clone();
let window =
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_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)
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
.on_navigation(move |destination| {
if !allows_server_navigation(&server_url, destination) {
open_external(destination);
return false;
}
begin_navigation(&handle, &navigation_label);
true
})
.on_new_window(move |destination, _| {
if allows_server_navigation(&popup_server, &destination) {
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
let _ = window.navigate(destination);
}
} else {
open_external(&destination);
}
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())
|| state.phase != "connecting"
|| !state.server.as_ref().is_some_and(|server| {
allows_server_navigation(&server.url, payload.url())
})
{
return;
}
let first_load = !state.ready;
state.ready = true;
state.phase = "connected";
first_load
};
if should_show {
let _ = window.show();
let _ = window.set_focus();
if let Some(launcher) = app.get_webview_window("launcher") {
let _ = launcher.hide();
}
}
update_launcher(app);
})
.build()
.map_err(|_| "connectionError")?;
#[cfg(target_os = "macos")]
window
.with_webview(|webview| unsafe {
unsafe extern "C" {
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
}
install_workspace_navigation(webview.inner().cast());
})
.map_err(|_| "connectionError")?;
let handle = app.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let current = handle
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.label
.as_deref()
== Some(&label);
if current {
api.prevent_close();
handle.exit(0);
}
}
});
Ok(())
}
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() {
"/action/updates" => {
#[cfg(target_os = "macos")]
crate::updates::show(app);
Ok(())
}
"/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> {
tauri::generate_context!()
}
fn about_metadata(version: String) -> AboutMetadata<'static> {
AboutMetadata {
name: Some(en::APP_NAME.into()),
version: Some(version),
#[cfg(target_os = "macos")]
short_version: Some(crate::metadata::build_number().into()),
..Default::default()
}
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
Ok(Some(server)) => (Some(server), "connecting"),
Ok(None) => (None, "idle"),
Err(_) => (None, "storageError"),
};
let startup_server = server.clone();
app.manage(Mutex::new(Connection {
server,
phase,
..Default::default()
}));
let menu = MenuBuilder::new(app)
.item(
&SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
.about_with_text(
en::ABOUT,
Some(about_metadata(app.package_info().version.to_string())),
)
.text("check-updates", en::CHECK_UPDATES)
.item(&MenuItem::with_id(
app,
"install-update",
en::INSTALL_UPDATE,
false,
None::<&str>,
)?)
.separator()
.text("server-settings", en::SERVER_SETTINGS)
.separator()
.quit()
.build()?,
)
.item(
&SubmenuBuilder::new(app, en::EDIT)
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?,
)
.item(
&SubmenuBuilder::new(app, en::NAVIGATION)
.text("workspace-back", en::BACK)
.text("workspace-forward", en::FORWARD)
.build()?,
)
.build()?;
app.set_menu(menu)?;
#[cfg(target_os = "macos")]
crate::updates::setup(app.handle())?;
app.on_menu_event(|app, event| {
#[cfg(target_os = "macos")]
match event.id().as_ref() {
"check-updates" => {
crate::updates::check(app, true);
return;
}
"install-update" => {
crate::updates::show(app);
return;
}
_ => {}
}
if event.id().as_ref() == "server-settings" {
show_launcher(app);
} else if let Some(script) = match event.id().as_ref() {
"workspace-back" => Some("window.history.back()"),
"workspace-forward" => Some("window.history.forward()"),
_ => None,
} {
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.eval(script);
}
}
});
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)
.visible(false)
.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);
#[cfg(target_os = "macos")]
crate::updates::refresh(&app);
let needs_setup = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.server
.is_none();
if needs_setup {
show_launcher(&app);
}
}
})
.build()?;
let window = launcher.clone();
launcher.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let has_workspace = window
.app_handle()
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.ready;
if has_workspace {
let _ = window.hide();
} else {
window.app_handle().exit(0);
}
}
});
if let Some(server) = startup_server {
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app.handle());
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn about_shows_app_version_followed_by_the_actual_build_number() {
let metadata = about_metadata("9.8.7".into());
assert_eq!(metadata.version.as_deref(), Some("9.8.7"));
assert_eq!(
metadata.short_version.as_deref(),
Some(crate::metadata::build_number())
);
assert_ne!(metadata.version, metadata.short_version);
}
#[test]
fn external_links_accept_only_web_urls_without_credentials() {
for input in [
"https://git.example.com/org/repo/pulls/1#diff",
"http://git.example.com/repo",
] {
assert!(allows_external_navigation(&Url::parse(input).unwrap()));
}
for input in [
"https://user:secret@git.example.com/",
"file:///etc/passwd",
"javascript:alert(1)",
"tauri://localhost/action/open",
"mailto:user@example.com",
"data:text/html,test",
] {
assert!(!allows_external_navigation(&Url::parse(input).unwrap()));
}
}
#[test]
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=52#details",
] {
assert!(allows_server_navigation(
&server,
&server.join(path).unwrap()
));
}
for destination in [
"http://example.com:8443/",
"https://example.com/",
"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()
));
}
}
#[test]
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 builder = tauri::test::mock_builder();
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
let app = builder.build(context()).unwrap();
let window = WebviewWindowBuilder::new(
&app,
"workspace-1",
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
)
.build()
.unwrap();
for command in [
"plugin:updater|check",
"plugin:updater|download_and_install",
"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(DEFAULT_SERVER).unwrap(),
body: tauri::ipc::InvokeBody::default(),
headers: Default::default(),
invoke_key: tauri::test::INVOKE_KEY.into(),
},
);
assert!(response
.unwrap_err()
.as_str()
.unwrap()
.contains("not allowed"));
}
}
#[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["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
assert_eq!(config["app"]["withGlobalTauri"], false);
assert_eq!(
config["app"]["security"]["capabilities"],
serde_json::json!([])
);
}
}