0de53279c0
Hop-State: A_06FPHFY6SA0R5CXMBRZHG08 Hop-Proposal: R_06FPHFX326Q7FJA5Z4Y4Y08 Hop-Task: T_06FPHEQ8RZX6R1ZVV8WQ43R Hop-Attempt: AT_06FPHEQ8RZVSEZXZ9E88YP0
589 lines
19 KiB
Rust
589 lines
19 KiB
Rust
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!("Couldn’t 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!("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<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!("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 = '<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!("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::<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]));
|
||
}
|
||
}
|