Enable reliable native window dragging and hand authenticated sessions into the selected node's complete web experience
Hop-State: A_06FPG2TG1QGADXPK4Z3JRK8 Hop-Proposal: R_06FPG2STVE3YFFK1MMCXN7R Hop-Task: T_06FPG0V9Q74727J7JHZVD20 Hop-Attempt: AT_06FPG0V9Q4RS0GRDVR1GXVG
This commit is contained in:
@@ -4,9 +4,9 @@ A Tauri desktop client for the Synapsis federated social network. The first-run
|
|||||||
|
|
||||||
1. Choose and verify a Synapsis node.
|
1. Choose and verify a Synapsis node.
|
||||||
2. Sign in with an existing account or create a new account on that node.
|
2. Sign in with an existing account or create a new account on that node.
|
||||||
3. Enter the signed-in desktop shell.
|
3. Continue directly into that node's real signed-in web experience.
|
||||||
|
|
||||||
Node and authentication requests run in Rust so independent nodes do not need to opt into browser CORS. Session cookies are held by the native HTTP client and are not exposed to the React webview.
|
Node and authentication requests run in Rust so independent nodes do not need to opt into browser CORS. After authentication, HttpOnly session cookies are securely handed to the native webview and the selected node's actual web client opens in the same window.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ cargo test --manifest-path src-tauri/Cargo.toml
|
|||||||
|
|
||||||
- Animated signal-field launch and onboarding visuals.
|
- Animated signal-field launch and onboarding visuals.
|
||||||
- Node URL normalization and `/api/node` verification.
|
- Node URL normalization and `/api/node` verification.
|
||||||
- Existing-session detection while the app is running.
|
- Persistent web-session detection across app launches.
|
||||||
- Cookie-based sign in, account creation, and sign out.
|
- Cookie-based sign in, account creation, and sign out.
|
||||||
- A desktop navigation shell ready for the feed and other iOS features.
|
- The selected node's complete website experience after authentication.
|
||||||
|
|
||||||
The node address persists between launches. Authentication cookies currently live for the duration of the application process; durable, encrypted session restoration is a later security-focused addition.
|
The node address and HttpOnly website session persist between launches using the native webview's cookie store.
|
||||||
|
|||||||
Generated
+1
@@ -3338,6 +3338,7 @@ dependencies = [
|
|||||||
name = "synapsis-desktop"
|
name = "synapsis-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"http",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ tauri = { version = "2", features = [] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http = "1"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default"
|
"core:default",
|
||||||
|
"core:window:allow-start-dragging"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-7
@@ -1,7 +1,8 @@
|
|||||||
use reqwest::{Client, Method, StatusCode, Url};
|
use reqwest::{Client, Method, StatusCode, Url};
|
||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::State;
|
use tauri::webview::Cookie;
|
||||||
|
use tauri::{State, WebviewWindow};
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -116,8 +117,35 @@ async fn get<T: DeserializeOwned>(client: &Client, base: &Url, path: &str) -> Re
|
|||||||
decode_response(response).await
|
decode_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post<T: DeserializeOwned, B: Serialize>(
|
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,
|
client: &Client,
|
||||||
|
webview: &WebviewWindow,
|
||||||
base: &Url,
|
base: &Url,
|
||||||
path: &str,
|
path: &str,
|
||||||
body: &B,
|
body: &B,
|
||||||
@@ -129,7 +157,15 @@ async fn post<T: DeserializeOwned, B: Serialize>(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("Couldn’t reach this node: {error}"))?;
|
.map_err(|error| format!("Couldn’t reach this node: {error}"))?;
|
||||||
decode_response(response).await
|
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]
|
#[tauri::command]
|
||||||
@@ -152,13 +188,15 @@ async fn connect(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn login(
|
async fn login(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
webview: WebviewWindow,
|
||||||
node_url: String,
|
node_url: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<AuthPayload, String> {
|
) -> Result<AuthPayload, String> {
|
||||||
let base = normalize_node(&node_url)?;
|
let base = normalize_node(&node_url)?;
|
||||||
let payload = post::<SessionPayload, _>(
|
let payload = post_and_sync_cookies::<SessionPayload, _>(
|
||||||
&state.client,
|
&state.client,
|
||||||
|
&webview,
|
||||||
&base,
|
&base,
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
&serde_json::json!({ "email": email.trim(), "password": password }),
|
&serde_json::json!({ "email": email.trim(), "password": password }),
|
||||||
@@ -173,6 +211,7 @@ async fn login(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn register(
|
async fn register(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
webview: WebviewWindow,
|
||||||
node_url: String,
|
node_url: String,
|
||||||
handle: String,
|
handle: String,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
@@ -186,14 +225,39 @@ async fn register(
|
|||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password: &password,
|
password: &password,
|
||||||
};
|
};
|
||||||
let payload =
|
let payload = post_and_sync_cookies::<SessionPayload, _>(
|
||||||
post::<SessionPayload, _>(&state.client, &base, "/api/auth/register", &body).await?;
|
&state.client,
|
||||||
|
&webview,
|
||||||
|
&base,
|
||||||
|
"/api/auth/register",
|
||||||
|
&body,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
payload
|
payload
|
||||||
.user
|
.user
|
||||||
.map(|user| AuthPayload { user })
|
.map(|user| AuthPayload { user })
|
||||||
.ok_or_else(|| "The node returned an unexpected response.".into())
|
.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(cookies
|
||||||
|
.iter()
|
||||||
|
.any(|cookie| cookie.name() == "synapsis_sessions" && !cookie.value().trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_web_app(webview: WebviewWindow, node_url: String) -> Result<(), String> {
|
||||||
|
let base = normalize_node(&node_url)?;
|
||||||
|
webview
|
||||||
|
.navigate(base)
|
||||||
|
.map_err(|error| format!("Couldn’t open the Synapsis web app: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> {
|
async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> {
|
||||||
let base = normalize_node(&node_url)?;
|
let base = normalize_node(&node_url)?;
|
||||||
@@ -222,7 +286,14 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(AppState { client })
|
.manage(AppState { client })
|
||||||
.invoke_handler(tauri::generate_handler![connect, login, register, logout])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
connect,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
has_web_session,
|
||||||
|
open_web_app
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Synapsis");
|
.expect("error while running Synapsis");
|
||||||
}
|
}
|
||||||
@@ -253,4 +324,26 @@ mod tests {
|
|||||||
fn rejects_non_web_schemes() {
|
fn rejects_non_web_schemes() {
|
||||||
assert!(normalize_node("file:///tmp/node").is_err());
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-12
@@ -238,16 +238,31 @@ function App() {
|
|||||||
setBootstrapping(false);
|
setBootstrapping(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
invoke<Connection>("connect", { nodeUrl: savedNode })
|
async function restore() {
|
||||||
.then((connection) => {
|
try {
|
||||||
|
const hasWebSession = await invoke<boolean>("has_web_session", { nodeUrl: savedNode });
|
||||||
|
if (hasWebSession) {
|
||||||
|
await invoke("open_web_app", { nodeUrl: savedNode });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await invoke<Connection>("connect", { nodeUrl: savedNode });
|
||||||
setNodeUrl(connection.nodeUrl);
|
setNodeUrl(connection.nodeUrl);
|
||||||
setNode(connection.node);
|
setNode(connection.node);
|
||||||
setNodeInput(displayHost(connection.nodeUrl));
|
setNodeInput(displayHost(connection.nodeUrl));
|
||||||
setUser(connection.user);
|
setUser(connection.user);
|
||||||
setScreen(connection.user ? "home" : "login");
|
if (connection.user) {
|
||||||
})
|
await invoke("open_web_app", { nodeUrl: connection.nodeUrl });
|
||||||
.catch(() => setScreen("node"))
|
} else {
|
||||||
.finally(() => setBootstrapping(false));
|
setScreen("login");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setScreen("node");
|
||||||
|
} finally {
|
||||||
|
setBootstrapping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void restore();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function connect(event: FormEvent) {
|
async function connect(event: FormEvent) {
|
||||||
@@ -264,7 +279,11 @@ function App() {
|
|||||||
setNodeUrl(connection.nodeUrl);
|
setNodeUrl(connection.nodeUrl);
|
||||||
setNode(connection.node);
|
setNode(connection.node);
|
||||||
setUser(connection.user);
|
setUser(connection.user);
|
||||||
setScreen(connection.user ? "home" : "login");
|
if (connection.user) {
|
||||||
|
await invoke("open_web_app", { nodeUrl: connection.nodeUrl });
|
||||||
|
} else {
|
||||||
|
setScreen("login");
|
||||||
|
}
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(readableError(caught));
|
setError(readableError(caught));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -284,7 +303,7 @@ function App() {
|
|||||||
password: data.get("password"),
|
password: data.get("password"),
|
||||||
});
|
});
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
setScreen("home");
|
await invoke("open_web_app", { nodeUrl });
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(readableError(caught));
|
setError(readableError(caught));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -311,7 +330,7 @@ function App() {
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
setScreen("home");
|
await invoke("open_web_app", { nodeUrl });
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(readableError(caught));
|
setError(readableError(caught));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -343,7 +362,7 @@ function App() {
|
|||||||
const name = user.displayName?.trim() || user.handle.replace(/^@/, "").split("@")[0];
|
const name = user.displayName?.trim() || user.handle.replace(/^@/, "").split("@")[0];
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<aside className="sidebar" data-tauri-drag-region>
|
<aside className="sidebar" data-tauri-drag-region="deep">
|
||||||
<div className="sidebar-brand"><Wordmark className="sidebar-wordmark"/></div>
|
<div className="sidebar-brand"><Wordmark className="sidebar-wordmark"/></div>
|
||||||
<nav aria-label="Main navigation">
|
<nav aria-label="Main navigation">
|
||||||
<button className="nav-item active"><Icon name="home"/><span>Home</span></button>
|
<button className="nav-item active"><Icon name="home"/><span>Home</span></button>
|
||||||
@@ -360,7 +379,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section className="home-content">
|
<section className="home-content">
|
||||||
<header className="home-header" data-tauri-drag-region><h1>Home</h1><span className="connected-pill"><i/>Connected</span></header>
|
<header className="home-header" data-tauri-drag-region="deep"><h1>Home</h1><span className="connected-pill"><i/>Connected</span></header>
|
||||||
<div className="home-empty">
|
<div className="home-empty">
|
||||||
<div className="orbit-mark"><span/><span/><i/></div>
|
<div className="orbit-mark"><span/><span/><i/></div>
|
||||||
<p className="eyebrow">SESSION ESTABLISHED</p>
|
<p className="eyebrow">SESSION ESTABLISHED</p>
|
||||||
@@ -374,7 +393,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-shell">
|
<main className="onboarding-shell">
|
||||||
<section className="signal-panel" data-tauri-drag-region>
|
<section className="signal-panel" data-tauri-drag-region="deep">
|
||||||
<SignalField/>
|
<SignalField/>
|
||||||
<div className="signal-shade"/>
|
<div className="signal-shade"/>
|
||||||
<div className="panel-brand"><Wordmark className="panel-wordmark"/></div>
|
<div className="panel-brand"><Wordmark className="panel-wordmark"/></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user