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
611 lines
21 KiB
Rust
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!([])
|
|
);
|
|
}
|
|
}
|