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
This commit is contained in:
+12
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
+135
-23
@@ -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<Option<Url>>,
|
||||
window_sequence: AtomicU64,
|
||||
entry_urls: Mutex<HashMap<String, Url>>,
|
||||
}
|
||||
|
||||
#[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::<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}");
|
||||
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::<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(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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user