use reqwest::{Client, Method, StatusCode, Url}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; use tauri::webview::{Cookie, PageLoadEvent}; use tauri::{Manager, State, Webview, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; struct AppState { client: Client, notification_capability_sequence: AtomicU64, window_sequence: AtomicU64, entry_urls: Mutex>, } #[derive(Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct NodeInfo { name: Option, description: Option, accent_color: Option, domain: Option, logo_url: Option, } #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct User { id: String, handle: String, display_name: Option, avatar_url: Option, } #[derive(Deserialize)] struct SessionPayload { user: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ConnectionPayload { node_url: String, node: NodeInfo, user: Option, } #[derive(Serialize)] struct AuthPayload { user: User, } #[derive(Deserialize)] struct ErrorPayload { error: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct RegistrationRequest<'a> { handle: &'a str, display_name: &'a str, email: &'a str, password: &'a str, } fn normalize_node(input: &str) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { return Err("Enter a valid Synapsis node URL.".into()); } let candidate = if trimmed.contains("://") { trimmed.to_owned() } else { format!("https://{trimmed}") }; let mut url = Url::parse(&candidate).map_err(|_| "Enter a valid Synapsis node URL.")?; if url.scheme() != "http" && url.scheme() != "https" { return Err("Synapsis nodes must use an http or https URL.".into()); } if url.host_str().is_none() { return Err("Enter a valid Synapsis node URL.".into()); } if url.host_str() == Some("www.synapsis.social") { url.set_host(Some("synapsis.social")) .map_err(|_| "Enter a valid Synapsis node URL.")?; } url.set_username("").ok(); url.set_password(None).ok(); url.set_path("/"); url.set_query(None); url.set_fragment(None); Ok(url) } fn endpoint(base: &Url, path: &str) -> Result { base.join(path) .map_err(|_| "The selected node address is invalid.".into()) } async fn decode_response(response: reqwest::Response) -> Result { let status = response.status(); let bytes = response.bytes().await.map_err(|error| error.to_string())?; if !status.is_success() { let message = serde_json::from_slice::(&bytes) .ok() .and_then(|payload| payload.error) .unwrap_or_else(|| format!("Request failed ({status}).")); return Err(message); } serde_json::from_slice(&bytes).map_err(|_| "The node returned an unexpected response.".into()) } async fn get(client: &Client, base: &Url, path: &str) -> Result { let response = client .get(endpoint(base, path)?) .header("Accept", "application/json") .send() .await .map_err(|error| format!("Couldn’t reach this node: {error}"))?; decode_response(response).await } fn cookies_for_webview(response: &reqwest::Response, base: &Url) -> Vec> { let Some(host) = base.host_str() else { return Vec::new(); }; response .headers() .get_all(reqwest::header::SET_COOKIE) .iter() .filter_map(|value| value.to_str().ok()) .filter_map(|value| Cookie::parse(value.to_owned()).ok()) .map(|mut cookie| { if cookie.domain().is_none() { cookie.set_domain(host.to_owned()); } if cookie.path().is_none() { cookie.set_path("/"); } if cookie.secure().is_none() { cookie.set_secure(base.scheme() == "https"); } cookie.into_owned() }) .collect() } async fn post_and_sync_cookies( client: &Client, webview: &WebviewWindow, base: &Url, path: &str, body: &B, ) -> Result { let response = client .request(Method::POST, endpoint(base, path)?) .header("Accept", "application/json") .json(body) .send() .await .map_err(|error| format!("Couldn’t reach this node: {error}"))?; let cookies = cookies_for_webview(&response, base); let payload = decode_response(response).await?; for cookie in cookies { webview .set_cookie(cookie) .map_err(|error| format!("Couldn’t prepare the web session: {error}"))?; } Ok(payload) } #[tauri::command] async fn connect( state: State<'_, AppState>, node_url: String, ) -> Result { let base = normalize_node(&node_url)?; let node = get::(&state.client, &base, "/api/node").await?; let session = get::(&state.client, &base, "/api/auth/me") .await .unwrap_or(SessionPayload { user: None }); Ok(ConnectionPayload { node_url: base.to_string(), node, user: session.user, }) } #[tauri::command] async fn login( state: State<'_, AppState>, webview: WebviewWindow, node_url: String, email: String, password: String, ) -> Result { let base = normalize_node(&node_url)?; let payload = post_and_sync_cookies::( &state.client, &webview, &base, "/api/auth/login", &serde_json::json!({ "email": email.trim(), "password": password }), ) .await?; payload .user .map(|user| AuthPayload { user }) .ok_or_else(|| "The node returned an unexpected response.".into()) } #[tauri::command] async fn register( state: State<'_, AppState>, webview: WebviewWindow, node_url: String, handle: String, display_name: String, email: String, password: String, ) -> Result { let base = normalize_node(&node_url)?; let body = RegistrationRequest { handle: handle.trim().trim_start_matches('@'), display_name: display_name.trim(), email: email.trim(), password: &password, }; let payload = post_and_sync_cookies::( &state.client, &webview, &base, "/api/auth/register", &body, ) .await?; payload .user .map(|user| AuthPayload { user }) .ok_or_else(|| "The node returned an unexpected response.".into()) } #[tauri::command] async fn has_web_session(webview: WebviewWindow, node_url: String) -> Result { let base = normalize_node(&node_url)?; let cookies = webview .cookies_for_url(base) .map_err(|error| format!("Couldn’t restore the web session: {error}"))?; Ok(has_synapsis_session(&cookies)) } fn has_synapsis_session(cookies: &[Cookie<'_>]) -> bool { cookies .iter() .any(|cookie| cookie.name() == "synapsis_sessions" && !cookie.value().trim().is_empty()) } fn is_logged_out_explore(url: &Url, cookies: &[Cookie<'_>]) -> bool { url.path().trim_end_matches('/') == "/explore" && !has_synapsis_session(cookies) } #[tauri::command] async fn open_web_app( state: State<'_, AppState>, webview: WebviewWindow, node_url: String, ) -> Result<(), String> { let base = normalize_node(&node_url)?; { let mut entry_urls = state .entry_urls .lock() .map_err(|_| "Couldn’t remember the app entry screen.".to_string())?; entry_urls.entry(webview.label().to_owned()).or_insert( webview .url() .map_err(|error| format!("Couldn’t read the app entry address: {error}"))?, ); } let origin = base.origin().ascii_serialization(); let capability_id = state .notification_capability_sequence .fetch_add(1, Ordering::Relaxed); let capability = remote_node_capability(&origin, webview.label(), capability_id); webview .add_capability(capability) .map_err(|error| format!("Couldn’t enable native notifications for this node: {error}"))?; webview .navigate(base) .map_err(|error| format!("Couldn’t open the Synapsis web app: {error}")) } fn remote_node_capability(origin: &str, window_label: &str, sequence: u64) -> String { serde_json::json!({ "identifier": format!("synapsis-node-desktop-{sequence}"), "description": "Allow an active Synapsis node to use its desktop window controls", "local": false, "windows": [window_label], "remote": { "urls": [format!("{origin}/*")] }, "permissions": ["notification:default", "allow-open-login-window"] }) .to_string() } fn new_window_button_script(accent_color: Option<&str>) -> String { let accent = serde_json::to_string(accent_color.unwrap_or("#7c5cff")) .expect("serializing an accent color cannot fail"); r##" (() => { const id = "synapsis-new-node-window"; const styleId = `${id}-style`; document.getElementById(id)?.remove(); document.getElementById(styleId)?.remove(); const requestedAccent = __ACCENT__; const accent = CSS.supports("color", requestedAccent) ? requestedAccent : "#7c5cff"; const style = document.createElement("style"); style.id = styleId; style.textContent = ` #${id} { position: fixed; right: 28px; bottom: 28px; z-index: 2147483647; width: 54px; height: 54px; display: grid; place-items: center; padding: 0; border: 1px solid rgba(255,255,255,.22); border-radius: 50%; background: ${accent}; box-shadow: 0 13px 34px rgba(0,0,0,.38), 0 0 22px color-mix(in srgb, ${accent} 38%, transparent); cursor: pointer; transition: transform 160ms ease, filter 160ms ease, opacity 160ms ease; } #${id} svg { display: block; width: 22px; height: 22px; } #${id}:hover { transform: translateY(-2px) scale(1.04); filter: brightness(1.08); } #${id}:active { transform: scale(.96); } #${id}:focus-visible { outline: 2px solid #fff; outline-offset: 4px; } #${id}:disabled { cursor: wait; opacity: .62; } @media (prefers-reduced-motion: reduce) { #${id} { transition: none; } } `; const button = document.createElement("button"); button.id = id; button.type = "button"; button.innerHTML = ''; button.setAttribute("aria-label", "Open another Synapsis node tab"); button.title = "Open another node tab"; button.addEventListener("click", async () => { button.disabled = true; try { await window.__TAURI_INTERNALS__.invoke("open_login_window"); } catch (error) { console.error("Could not open another Synapsis node tab", error); } finally { button.disabled = false; } }); document.head.appendChild(style); document.documentElement.appendChild(button); })(); "## .replace("__ACCENT__", &accent) } async fn install_new_window_button(client: Client, webview: Webview, page_url: Url) { let accent_color = if let Ok(base) = normalize_node(page_url.as_str()) { get::(&client, &base, "/api/node") .await .ok() .and_then(|node| node.accent_color) } else { None }; let _ = webview.eval(new_window_button_script(accent_color.as_deref())); } #[tauri::command] async fn open_login_window( app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let sequence = state.window_sequence.fetch_add(1, Ordering::Relaxed); let label = format!("node-{sequence}"); let builder = WebviewWindowBuilder::new( &app, label, WebviewUrl::App("index.html?newWindow=1".into()), ) .initialization_script("window.__SYNAPSIS_FRESH_LOGIN__ = true;") .incognito(true) .title("Synapsis") .inner_size(1280.0, 820.0) .min_inner_size(720.0, 560.0) .center() .theme(Some(tauri::Theme::Dark)) .background_color(tauri::webview::Color(8, 9, 9, 255)); #[cfg(target_os = "macos")] let builder = builder .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Visible) .tabbing_identifier("synapsis-node-tabs"); builder .build() .map(|_| ()) .map_err(|error| format!("Couldn’t open another Synapsis node tab: {error}")) } #[tauri::command] async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> { let base = normalize_node(&node_url)?; let response = state .client .post(endpoint(&base, "/api/auth/logout")?) .json(&serde_json::json!({})) .send() .await .map_err(|error| format!("Couldn’t reach this node: {error}"))?; if response.status().is_success() || response.status() == StatusCode::UNAUTHORIZED { Ok(()) } else { Err(format!("Log out failed ({}).", response.status())) } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let client = Client::builder() .cookie_store(true) .timeout(Duration::from_secs(20)) .user_agent("Synapsis Desktop/0.1") .build() .expect("failed to build Synapsis HTTP client"); tauri::Builder::default() .plugin(tauri_plugin_notification::init()) .manage(AppState { client, notification_capability_sequence: AtomicU64::new(0), window_sequence: AtomicU64::new(1), entry_urls: Mutex::new(HashMap::new()), }) .on_page_load(|webview, payload| { if payload.event() != PageLoadEvent::Finished || !matches!(payload.url().scheme(), "http" | "https") { return; } let Ok(cookies) = webview.cookies_for_url(payload.url().clone()) else { return; }; if has_synapsis_session(&cookies) { let client = webview.state::().client.clone(); let target = webview.clone(); let page_url = payload.url().clone(); tauri::async_runtime::spawn(install_new_window_button(client, target, page_url)); } if !is_logged_out_explore(payload.url(), &cookies) { return; } let state = webview.state::(); let mut entry_url = { let Ok(entry_urls) = state.entry_urls.lock() else { return; }; let Some(entry_url) = entry_urls.get(webview.label()).cloned() else { return; }; entry_url }; let client = state.client.clone(); let node_url = payload.url().clone(); tauri::async_runtime::spawn(async move { if let Ok(base) = normalize_node(node_url.as_str()) { if let Ok(logout_url) = endpoint(&base, "/api/auth/logout") { let _ = client .post(logout_url) .json(&serde_json::json!({})) .send() .await; } } }); entry_url.set_query(Some("signedOut=1")); let _ = webview.navigate(entry_url); }) .invoke_handler(tauri::generate_handler![ connect, login, register, logout, has_web_session, open_web_app, open_login_window ]) .run(tauri::generate_context!()) .expect("error while running Synapsis"); } #[cfg(test)] mod tests { use super::*; #[test] fn normalizes_bare_node_names() { assert_eq!( normalize_node("synapsis.social").unwrap().as_str(), "https://synapsis.social/" ); } #[test] fn strips_paths_credentials_and_fragments() { assert_eq!( normalize_node("https://person:secret@example.com/somewhere?q=1#fragment") .unwrap() .as_str(), "https://example.com/" ); } #[test] fn rejects_non_web_schemes() { assert!(normalize_node("file:///tmp/node").is_err()); } #[test] fn prepares_host_only_session_cookies_for_the_webview() { let base = normalize_node("https://example.com").unwrap(); let response = reqwest::Response::from( http::Response::builder() .header( reqwest::header::SET_COOKIE, "synapsis_session=abc; Path=/; HttpOnly; Secure; SameSite=Lax", ) .body("") .unwrap(), ); let cookies = cookies_for_webview(&response, &base); assert_eq!(cookies.len(), 1); assert_eq!(cookies[0].name(), "synapsis_session"); assert_eq!(cookies[0].domain(), Some("example.com")); assert_eq!(cookies[0].path(), Some("/")); assert_eq!(cookies[0].http_only(), Some(true)); assert_eq!(cookies[0].secure(), Some(true)); } #[test] fn scopes_native_notifications_to_the_selected_node_origin() { let capability = remote_node_capability("https://example.com", "node-3", 7); let value: serde_json::Value = serde_json::from_str(&capability).unwrap(); assert_eq!(value["identifier"], "synapsis-node-desktop-7"); assert_eq!(value["local"], false); assert_eq!(value["windows"][0], "node-3"); assert_eq!(value["remote"]["urls"][0], "https://example.com/*"); assert_eq!(value["permissions"][0], "notification:default"); assert_eq!(value["permissions"][1], "allow-open-login-window"); } #[test] fn escapes_node_accents_in_the_injected_button_script() { let script = new_window_button_script(Some("#12abef\"; alert(1); //")); assert!(script.contains(r##"const requestedAccent = "#12abef\"; alert(1); //";"##)); assert!(script.contains("CSS.supports")); assert!(script.contains("stroke=\"black\"")); assert!(script.contains("open_login_window")); } #[test] fn returns_to_entry_only_for_logged_out_explore() { let explore = Url::parse("https://example.com/explore").unwrap(); let timeline = Url::parse("https://example.com/").unwrap(); let active_cookie = Cookie::new("synapsis_sessions", "active"); assert!(is_logged_out_explore(&explore, &[])); assert!(!is_logged_out_explore(&timeline, &[])); assert!(!is_logged_out_explore(&explore, &[active_cookie])); } }