Files
Synapsis-Desktop/src-tauri/src/lib.rs
T
cyph3rasi 0de53279c0 Open isolated node login surfaces as native macOS tabs with a centered black plus control
Hop-State: A_06FPHFY6SA0R5CXMBRZHG08
Hop-Proposal: R_06FPHFX326Q7FJA5Z4Y4Y08
Hop-Task: T_06FPHEQ8RZX6R1ZVV8WQ43R
Hop-Attempt: AT_06FPHEQ8RZVSEZXZ9E88YP0
2026-07-15 19:27:06 -07:00

589 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HashMap<String, Url>>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NodeInfo {
name: Option<String>,
description: Option<String>,
accent_color: Option<String>,
domain: Option<String>,
logo_url: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct User {
id: String,
handle: String,
display_name: Option<String>,
avatar_url: Option<String>,
}
#[derive(Deserialize)]
struct SessionPayload {
user: Option<User>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectionPayload {
node_url: String,
node: NodeInfo,
user: Option<User>,
}
#[derive(Serialize)]
struct AuthPayload {
user: User,
}
#[derive(Deserialize)]
struct ErrorPayload {
error: Option<String>,
}
#[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<Url, String> {
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<Url, String> {
base.join(path)
.map_err(|_| "The selected node address is invalid.".into())
}
async fn decode_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, String> {
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::<ErrorPayload>(&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<T: DeserializeOwned>(client: &Client, base: &Url, path: &str) -> Result<T, String> {
let response = client
.get(endpoint(base, path)?)
.header("Accept", "application/json")
.send()
.await
.map_err(|error| format!("Couldnt reach this node: {error}"))?;
decode_response(response).await
}
fn cookies_for_webview(response: &reqwest::Response, base: &Url) -> Vec<Cookie<'static>> {
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<T: DeserializeOwned, B: Serialize>(
client: &Client,
webview: &WebviewWindow,
base: &Url,
path: &str,
body: &B,
) -> Result<T, String> {
let response = client
.request(Method::POST, endpoint(base, path)?)
.header("Accept", "application/json")
.json(body)
.send()
.await
.map_err(|error| format!("Couldnt 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!("Couldnt prepare the web session: {error}"))?;
}
Ok(payload)
}
#[tauri::command]
async fn connect(
state: State<'_, AppState>,
node_url: String,
) -> Result<ConnectionPayload, String> {
let base = normalize_node(&node_url)?;
let node = get::<NodeInfo>(&state.client, &base, "/api/node").await?;
let session = get::<SessionPayload>(&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<AuthPayload, String> {
let base = normalize_node(&node_url)?;
let payload = post_and_sync_cookies::<SessionPayload, _>(
&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<AuthPayload, String> {
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::<SessionPayload, _>(
&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<bool, String> {
let base = normalize_node(&node_url)?;
let cookies = webview
.cookies_for_url(base)
.map_err(|error| format!("Couldnt 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(|_| "Couldnt remember the app entry screen.".to_string())?;
entry_urls.entry(webview.label().to_owned()).or_insert(
webview
.url()
.map_err(|error| format!("Couldnt 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!("Couldnt enable native notifications for this node: {error}"))?;
webview
.navigate(base)
.map_err(|error| format!("Couldnt 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 = '<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M11 2.5V19.5M2.5 11H19.5" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round"/></svg>';
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::<NodeInfo>(&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!("Couldnt 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!("Couldnt 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::<AppState>().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::<AppState>();
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]));
}
}