feat(KAN-53): add Android app and platform integration
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
use crate::locale::en;
|
||||
use crate::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,
|
||||
};
|
||||
|
||||
#[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_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 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(())
|
||||
}
|
||||
|
||||
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> {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default().setup(|app| {
|
||||
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::<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(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 app = tauri::test::mock_builder().build(context()).unwrap();
|
||||
let window = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"workspace-1",
|
||||
WebviewUrl::External(Url::parse(DEFAULT_SERVER).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(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!([])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user