From 7bdd460a52fb1577e39c29103b352668a8f28256 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 19:19:41 -0700 Subject: [PATCH] Add an accent-colored new-node button that opens independent login windows with scoped Tauri permissions Hop-State: A_06FPHE7W9CXE3QZY70FNMH8 Hop-Proposal: R_06FPHE77PJN045T67QKDFA0 Hop-Task: T_06FPHCF4TTA40Z7GKD237Z8 Hop-Attempt: AT_06FPHCF4TRY8K16J9218WER --- src-tauri/build.rs | 13 +- src-tauri/capabilities/default.json | 7 +- .../permissions/autogenerated/connect.toml | 11 ++ .../autogenerated/has_web_session.toml | 11 ++ .../permissions/autogenerated/login.toml | 11 ++ .../permissions/autogenerated/logout.toml | 11 ++ .../autogenerated/open_login_window.toml | 11 ++ .../autogenerated/open_web_app.toml | 11 ++ .../permissions/autogenerated/register.toml | 11 ++ src-tauri/permissions/local-app.toml | 12 ++ src-tauri/permissions/open-login-window.toml | 4 + src-tauri/src/lib.rs | 158 +++++++++++++++--- src/App.tsx | 6 + 13 files changed, 250 insertions(+), 27 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/connect.toml create mode 100644 src-tauri/permissions/autogenerated/has_web_session.toml create mode 100644 src-tauri/permissions/autogenerated/login.toml create mode 100644 src-tauri/permissions/autogenerated/logout.toml create mode 100644 src-tauri/permissions/autogenerated/open_login_window.toml create mode 100644 src-tauri/permissions/autogenerated/open_web_app.toml create mode 100644 src-tauri/permissions/autogenerated/register.toml create mode 100644 src-tauri/permissions/local-app.toml create mode 100644 src-tauri/permissions/open-login-window.toml diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..458c7d3 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,14 @@ fn main() { - tauri_build::build() + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + tauri_build::AppManifest::new().commands(&[ + "connect", + "login", + "register", + "logout", + "has_web_session", + "open_web_app", + "open_login_window", + ]), + )) + .unwrap(); } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b704cce..36e5ccd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,10 +1,11 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capability for local Synapsis windows", + "windows": ["main", "node-*"], "permissions": [ "core:default", - "notification:default" + "notification:default", + "local-app" ] } diff --git a/src-tauri/permissions/autogenerated/connect.toml b/src-tauri/permissions/autogenerated/connect.toml new file mode 100644 index 0000000..249f259 --- /dev/null +++ b/src-tauri/permissions/autogenerated/connect.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-connect" +description = "Enables the connect command without any pre-configured scope." +commands.allow = ["connect"] + +[[permission]] +identifier = "deny-connect" +description = "Denies the connect command without any pre-configured scope." +commands.deny = ["connect"] diff --git a/src-tauri/permissions/autogenerated/has_web_session.toml b/src-tauri/permissions/autogenerated/has_web_session.toml new file mode 100644 index 0000000..a30252b --- /dev/null +++ b/src-tauri/permissions/autogenerated/has_web_session.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-has-web-session" +description = "Enables the has_web_session command without any pre-configured scope." +commands.allow = ["has_web_session"] + +[[permission]] +identifier = "deny-has-web-session" +description = "Denies the has_web_session command without any pre-configured scope." +commands.deny = ["has_web_session"] diff --git a/src-tauri/permissions/autogenerated/login.toml b/src-tauri/permissions/autogenerated/login.toml new file mode 100644 index 0000000..d69c410 --- /dev/null +++ b/src-tauri/permissions/autogenerated/login.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-login" +description = "Enables the login command without any pre-configured scope." +commands.allow = ["login"] + +[[permission]] +identifier = "deny-login" +description = "Denies the login command without any pre-configured scope." +commands.deny = ["login"] diff --git a/src-tauri/permissions/autogenerated/logout.toml b/src-tauri/permissions/autogenerated/logout.toml new file mode 100644 index 0000000..14ffda2 --- /dev/null +++ b/src-tauri/permissions/autogenerated/logout.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-logout" +description = "Enables the logout command without any pre-configured scope." +commands.allow = ["logout"] + +[[permission]] +identifier = "deny-logout" +description = "Denies the logout command without any pre-configured scope." +commands.deny = ["logout"] diff --git a/src-tauri/permissions/autogenerated/open_login_window.toml b/src-tauri/permissions/autogenerated/open_login_window.toml new file mode 100644 index 0000000..a01ff47 --- /dev/null +++ b/src-tauri/permissions/autogenerated/open_login_window.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-login-window" +description = "Enables the open_login_window command without any pre-configured scope." +commands.allow = ["open_login_window"] + +[[permission]] +identifier = "deny-open-login-window" +description = "Denies the open_login_window command without any pre-configured scope." +commands.deny = ["open_login_window"] diff --git a/src-tauri/permissions/autogenerated/open_web_app.toml b/src-tauri/permissions/autogenerated/open_web_app.toml new file mode 100644 index 0000000..4300b89 --- /dev/null +++ b/src-tauri/permissions/autogenerated/open_web_app.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-web-app" +description = "Enables the open_web_app command without any pre-configured scope." +commands.allow = ["open_web_app"] + +[[permission]] +identifier = "deny-open-web-app" +description = "Denies the open_web_app command without any pre-configured scope." +commands.deny = ["open_web_app"] diff --git a/src-tauri/permissions/autogenerated/register.toml b/src-tauri/permissions/autogenerated/register.toml new file mode 100644 index 0000000..6373ede --- /dev/null +++ b/src-tauri/permissions/autogenerated/register.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-register" +description = "Enables the register command without any pre-configured scope." +commands.allow = ["register"] + +[[permission]] +identifier = "deny-register" +description = "Denies the register command without any pre-configured scope." +commands.deny = ["register"] diff --git a/src-tauri/permissions/local-app.toml b/src-tauri/permissions/local-app.toml new file mode 100644 index 0000000..943ae75 --- /dev/null +++ b/src-tauri/permissions/local-app.toml @@ -0,0 +1,12 @@ +[[permission]] +identifier = "local-app" +description = "Allows bundled Synapsis screens to connect, authenticate, and hand off to a node." +commands.allow = [ + "connect", + "login", + "register", + "logout", + "has_web_session", + "open_web_app", + "open_login_window", +] diff --git a/src-tauri/permissions/open-login-window.toml b/src-tauri/permissions/open-login-window.toml new file mode 100644 index 0000000..55d97cd --- /dev/null +++ b/src-tauri/permissions/open-login-window.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-open-login-window" +description = "Allows an authenticated node page to open a fresh Synapsis login window." +commands.allow = ["open_login_window"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33960ea..91a9bcc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,15 +1,17 @@ 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, WebviewWindow}; +use tauri::{Manager, State, Webview, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; struct AppState { client: Client, notification_capability_sequence: AtomicU64, - entry_url: Mutex>, + window_sequence: AtomicU64, + entry_urls: Mutex>, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -270,23 +272,21 @@ async fn open_web_app( ) -> Result<(), String> { let base = normalize_node(&node_url)?; { - let mut entry_url = state - .entry_url + let mut entry_urls = state + .entry_urls .lock() .map_err(|_| "Couldn’t remember the app entry screen.".to_string())?; - if entry_url.is_none() { - *entry_url = Some( - webview - .url() - .map_err(|error| format!("Couldn’t read the app entry address: {error}"))?, - ); - } + 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_notification_capability(&origin, capability_id); + 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}"))?; @@ -295,20 +295,113 @@ async fn open_web_app( .map_err(|error| format!("Couldn’t open the Synapsis web app: {error}")) } -fn remote_notification_capability(origin: &str, sequence: u64) -> String { +fn remote_node_capability(origin: &str, window_label: &str, sequence: u64) -> String { serde_json::json!({ - "identifier": format!("synapsis-node-notifications-{sequence}"), - "description": "Allow the active Synapsis node to send native desktop notifications", + "identifier": format!("synapsis-node-desktop-{sequence}"), + "description": "Allow an active Synapsis node to use its desktop window controls", "local": false, - "windows": ["main"], + "windows": [window_label], "remote": { "urls": [format!("{origin}/*")] }, - "permissions": ["notification:default"] + "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%; + color: #fff; background: ${accent}; + box-shadow: 0 13px 34px rgba(0,0,0,.38), 0 0 22px color-mix(in srgb, ${accent} 38%, transparent); + font: 300 32px/1 -apple-system, BlinkMacSystemFont, sans-serif; + cursor: pointer; -webkit-font-smoothing: antialiased; + transition: transform 160ms ease, filter 160ms ease, opacity 160ms ease; + } + #${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.textContent = "+"; + button.setAttribute("aria-label", "Open another Synapsis node window"); + button.title = "Open another node"; + 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 window", 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}"); + WebviewWindowBuilder::new( + &app, + label, + WebviewUrl::App("index.html?newWindow=1".into()), + ) + .title("Synapsis") + .inner_size(1280.0, 820.0) + .min_inner_size(720.0, 560.0) + .center() + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Visible) + .theme(Some(tauri::Theme::Dark)) + .background_color(tauri::webview::Color(8, 9, 9, 255)) + .build() + .map(|_| ()) + .map_err(|error| format!("Couldn’t open another Synapsis window: {error}")) +} + #[tauri::command] async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> { let base = normalize_node(&node_url)?; @@ -340,7 +433,8 @@ pub fn run() { .manage(AppState { client, notification_capability_sequence: AtomicU64::new(0), - entry_url: Mutex::new(None), + window_sequence: AtomicU64::new(1), + entry_urls: Mutex::new(HashMap::new()), }) .on_page_load(|webview, payload| { if payload.event() != PageLoadEvent::Finished @@ -352,16 +446,22 @@ pub fn run() { 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(saved_entry_url) = state.entry_url.lock() else { + let Ok(entry_urls) = state.entry_urls.lock() else { return; }; - let Some(entry_url) = saved_entry_url.clone() else { + let Some(entry_url) = entry_urls.get(webview.label()).cloned() else { return; }; entry_url @@ -390,7 +490,8 @@ pub fn run() { register, logout, has_web_session, - open_web_app + open_web_app, + open_login_window ]) .run(tauri::generate_context!()) .expect("error while running Synapsis"); @@ -447,13 +548,24 @@ mod tests { #[test] fn scopes_native_notifications_to_the_selected_node_origin() { - let capability = remote_notification_capability("https://example.com", 7); + 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-notifications-7"); + 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("open_login_window")); } #[test] diff --git a/src/App.tsx b/src/App.tsx index 9b360e6..b06f896 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -268,6 +268,12 @@ function App() { useEffect(() => { const launchParams = new URLSearchParams(window.location.search); + if (launchParams.has("newWindow")) { + window.history.replaceState({}, "", window.location.pathname); + setScreen("node"); + setBootstrapping(false); + return; + } if (launchParams.has("signedOut")) { localStorage.removeItem(SAVED_NODE_KEY); window.history.replaceState({}, "", window.location.pathname);