mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* fix(desktop): support a server hosted under a sub-path `normalize_server_url` rebuilt whatever you typed as `scheme://host[:port]` and dropped the path, so a self-hosted Sure behind a reverse proxy that mounts it under a prefix — `https://home.example.com/sure` — could never be added: the health check hit `{origin}/up`, where something else (or nothing) answers, and the app reported "Couldn't reach a Sure server at that address." The same bare origin was then used for the navigation, the SSO hand-off and the IPC capability grant, so even a passing check would have loaded the wrong site. The path was being dropped for a reason — people paste the URL they have in the clipboard, which is usually a deep link like `/sessions/new`. So rather than guess, let the server say where it is: keep the path in the canonical form, and have `check_server` probe `/up` from the address as typed back up to the origin, returning the first base that answers 200. A root-hosted server still resolves to its origin (one extra candidate, tried only after the specific one 404s), and a transport-level failure returns immediately instead of retrying a host that is not there. `check_server` now answers with that resolved base instead of a bool, and both callers save it rather than the raw input. Tests: the sub-path base, trailing slash / query / fragment trimming, the walk-up candidate list, ports preserved on every candidate, and the cap that still keeps the origin. * fix(desktop): carry the mounted base through SSO and deep links Follow-up to the sub-path support in the previous commit: saving a base with a path fixed adding and loading such a server, but left two flows resolving against the bare origin. The injected bridge only intercepted a form action shaped exactly `/auth/{provider}`, so a mounted server's `/sure/auth/{provider}` was never intercepted, and it emitted `location.origin`, which `begin_sso` would then reject as an unknown server. Match the prefix that precedes `/auth/` and emit the base it implies — empty at a domain root, so root-hosted deployments are unaffected. Deep links were gated on `target.server`, which `deep_link::parse` builds from host and port alone. Gate on the destination instead, and let `is_known_server` accept a URL that a saved base covers. `base_covers` matches only at a path boundary, so a saved `https://sure.example.com` does not cover `https://sure.example.com.evil.test`. Reported by automated review on the PR. * fix(desktop): cap candidates from the deep middle, not the shallow end The cap kept the deepest MAX_BASE_CANDIDATES - 1 candidates plus the origin, which discards the shallow ones in between — and a mount point is shallow. A server mounted at /sure, reached by a link pasted three segments into the app (`/sure/transactions/123/edit`), produced `.../edit`, `.../123`, `/sure/transactions`, origin: `/sure`, the only base that answers, was never probed, so the connection failed with "Couldn't reach a Sure server at that address" against a live server. Keep the address as typed plus the shallowest bases instead. Both readings survive the cap: the base typed exactly, and the mount a deep link sits under. Reported by @jjmata in review. * fix(desktop): bound discovery by mount depth, not candidate count Capping the candidate list drops whole mount depths silently: with a budget of four, `https://host/a/b/c/d/e` never probed `https://host/a/b/c`, so a server mounted there was undiscoverable. Bound the supported mount depth instead. The address as typed is always probed first, so a base entered exactly still works at any depth; the rest are every depth up to MAX_MOUNT_DEPTH, which keeps a pasted deep link finite (typed + 4 probes) without skipping a depth in between. Reported by coderabbitai in review.
155 lines
7.4 KiB
Rust
155 lines
7.4 KiB
Rust
pub mod badge;
|
|
pub mod commands;
|
|
pub mod deep_link;
|
|
pub mod menu;
|
|
pub mod notifications;
|
|
pub mod servers;
|
|
pub mod sso;
|
|
pub mod state;
|
|
pub mod window;
|
|
|
|
use state::AppState;
|
|
|
|
pub fn run() {
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_decorum::init())
|
|
.plugin(tauri_plugin_autostart::init(
|
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
|
None,
|
|
))
|
|
.plugin(tauri_plugin_deep_link::init())
|
|
.manage(AppState::default())
|
|
.invoke_handler(tauri::generate_handler![
|
|
commands::list_servers,
|
|
commands::add_server,
|
|
commands::remove_server,
|
|
commands::check_server,
|
|
commands::active_server,
|
|
commands::set_active_server,
|
|
commands::get_launch_at_login,
|
|
commands::set_launch_at_login,
|
|
commands::start_sso,
|
|
])
|
|
.setup(|app| {
|
|
window::setup(app)?;
|
|
let menu = menu::build(app.handle())?;
|
|
app.set_menu(menu)?;
|
|
app.on_menu_event(|app, event| menu::on_event(app, event.id().as_ref()));
|
|
notifications::register(app.handle());
|
|
badge::register(app.handle());
|
|
// Grant runtime IPC capabilities for every already-known server
|
|
// origin (saved + active) so the bridge works when the app resumes
|
|
// or switches to them — instead of a static wildcard capability.
|
|
for entry in servers::ServerStore::load() {
|
|
commands::grant_server_capability(app.handle(), &entry.url);
|
|
}
|
|
if let Some(active) = servers::load_active() {
|
|
commands::grant_server_capability(app.handle(), &active);
|
|
}
|
|
{
|
|
// Hide windows on close instead of destroying them, so reopening
|
|
// keeps working (a destroyed webview makes get_webview_window
|
|
// return None). For "main" this also lets the dock icon re-show
|
|
// it via the RunEvent::Reopen handler below.
|
|
use tauri::Manager;
|
|
for label in ["main", "prefs"] {
|
|
if let Some(win) = app.get_webview_window(label) {
|
|
let win_for_event = win.clone();
|
|
win.on_window_event(move |event| {
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
api.prevent_close();
|
|
let _ = win_for_event.hide();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
{
|
|
// The remote Sure page can emit events but cannot invoke custom
|
|
// commands, so SSO is triggered via an event instead of invoke.
|
|
use tauri::Listener;
|
|
let handle = app.handle().clone();
|
|
app.listen_any("sure://start-sso", move |event| {
|
|
#[derive(serde::Deserialize)]
|
|
struct StartSso {
|
|
server: String,
|
|
provider: String,
|
|
}
|
|
if let Ok(p) = serde_json::from_str::<StartSso>(event.payload()) {
|
|
if let Err(e) = commands::begin_sso(&handle, p.server, p.provider) {
|
|
eprintln!("[sure] start-sso failed: {e}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
use tauri::Manager;
|
|
use tauri_plugin_deep_link::DeepLinkExt;
|
|
let handle = app.handle().clone();
|
|
app.deep_link().on_open_url(move |event| {
|
|
for url in event.urls() {
|
|
let u = url.as_str();
|
|
// SSO handoff first: exchange the one-time code (bound to
|
|
// our stored PKCE verifier) for a session in the webview.
|
|
// POST it via a form so the verifier never appears in a
|
|
// URL / server log (RFC 7636 keeps the verifier secret).
|
|
if let Some(cb) = deep_link::parse_sso_callback(u) {
|
|
let pending = handle.state::<AppState>().pending_sso.lock().unwrap().take();
|
|
if let (deep_link::SsoCallback::Code(code), Some(p)) = (cb, pending) {
|
|
if let Some(w) = handle.get_webview_window("main") {
|
|
let action = format!("{}/sessions/desktop_exchange", p.server);
|
|
let js = format!(
|
|
"(function(){{var f=document.createElement('form');f.method='POST';f.action={};\
|
|
var c=document.createElement('input');c.type='hidden';c.name='code';c.value={};f.appendChild(c);\
|
|
var v=document.createElement('input');v.type='hidden';v.name='code_verifier';v.value={};f.appendChild(v);\
|
|
document.body.appendChild(f);f.submit();}})();",
|
|
serde_json::to_string(&action).unwrap_or_default(),
|
|
serde_json::to_string(&code).unwrap_or_default(),
|
|
serde_json::to_string(&p.verifier).unwrap_or_default(),
|
|
);
|
|
let _ = w.eval(&js);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// Generic sure://{host}/{path} navigation — only to a
|
|
// server the user has saved, so a malicious deep link
|
|
// can't load an arbitrary origin into the main webview.
|
|
if let Some(target) = deep_link::parse(u) {
|
|
// Gate on the destination, not the bare origin: a
|
|
// saved server may be mounted under a path, and the
|
|
// link's own path is what lands inside it.
|
|
let dest = format!("{}{}", target.server, target.path);
|
|
if servers::is_known_server(&dest) {
|
|
if let Some(w) = handle.get_webview_window("main") {
|
|
let _ = w.eval(&format!("window.location.assign({:?})", dest));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
})
|
|
.on_page_load(|window, payload| {
|
|
if payload.event() == tauri::webview::PageLoadEvent::Finished {
|
|
const BRIDGE: &str = include_str!("../../dist/bridge.js");
|
|
let _ = window.eval(BRIDGE);
|
|
}
|
|
})
|
|
.build(tauri::generate_context!())
|
|
.expect("error while running Sure Desktop")
|
|
.run(|app, event| {
|
|
// Clicking the dock icon (while the main window is hidden, not
|
|
// destroyed) fires Reopen — re-show and focus the main window.
|
|
if let tauri::RunEvent::Reopen { .. } = event {
|
|
use tauri::Manager;
|
|
if let Some(w) = app.get_webview_window("main") {
|
|
let _ = w.show();
|
|
let _ = w.set_focus();
|
|
}
|
|
}
|
|
});
|
|
}
|