From 016fcc6c940a7c3b90d2562fc1c984d16843add7 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 17:14:48 -0700 Subject: [PATCH] Add a native Nodes switcher and keep the launch screen gated through session handoff Hop-State: A_06FPGHN5480G5QDY6X262B0 Hop-Proposal: R_06FPGHM5F6DNCB08QXQS5ER Hop-Task: T_06FPGDYGAF0D1M389636J4G Hop-Attempt: AT_06FPGDYGAF0KT0QDZXYQS1R --- src-tauri/Cargo.lock | 25 +++++ src-tauri/Cargo.toml | 18 ++++ src-tauri/src/lib.rs | 247 ++++++++++++++++++++++++++++++++++++++++++- src/App.tsx | 18 ++-- 4 files changed, 300 insertions(+), 8 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7396846..fec0d91 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2267,9 +2267,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2289,6 +2297,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2349,6 +2358,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3677,6 +3699,9 @@ name = "synapsis-desktop" version = "0.1.0" dependencies = [ "http", + "objc2", + "objc2-app-kit", + "objc2-foundation", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d221f7f..6e5494c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,5 +24,23 @@ serde_json = "1" reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] } tauri-plugin-notification = "2" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = [ + "NSButton", + "NSCell", + "NSControl", + "NSLayoutConstraint", + "NSMenu", + "NSMenuItem", + "NSPopUpButton", + "NSResponder", + "NSTitlebarAccessoryViewController", + "NSView", + "NSViewController", + "NSWindow", +] } +objc2-foundation = { version = "0.3", features = ["NSArray", "NSString"] } + [dev-dependencies] http = "1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33960ea..d3404f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ use reqwest::{Client, Method, StatusCode, Url}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -10,6 +12,15 @@ struct AppState { client: Client, notification_capability_sequence: AtomicU64, entry_url: Mutex>, + node_registry: Mutex, + node_registry_path: Mutex>, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeRegistry { + nodes: Vec, + active: Option, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -98,6 +109,56 @@ fn endpoint(base: &Url, path: &str) -> Result { .map_err(|_| "The selected node address is invalid.".into()) } +fn load_node_registry(path: &Path) -> NodeRegistry { + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +fn persist_node_registry(state: &AppState, registry: &NodeRegistry) -> Result<(), String> { + let path = state + .node_registry_path + .lock() + .map_err(|_| "Couldn’t access the saved node list.".to_string())? + .clone(); + let Some(path) = path else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Couldn’t save the node list: {error}"))?; + } + let bytes = serde_json::to_vec_pretty(registry) + .map_err(|error| format!("Couldn’t encode the node list: {error}"))?; + fs::write(path, bytes).map_err(|error| format!("Couldn’t save the node list: {error}")) +} + +fn remember_node(state: &AppState, node: &Url) -> Result { + let node_url = node.as_str().to_owned(); + let snapshot = { + let mut registry = state + .node_registry + .lock() + .map_err(|_| "Couldn’t access the saved node list.".to_string())?; + registry.nodes.retain(|saved| saved != &node_url); + registry.nodes.push(node_url.clone()); + registry.active = Some(node_url); + registry.clone() + }; + persist_node_registry(state, &snapshot)?; + Ok(snapshot) +} + +#[tauri::command] +fn get_saved_nodes(state: State<'_, AppState>) -> Result { + state + .node_registry + .lock() + .map(|registry| registry.clone()) + .map_err(|_| "Couldn’t access the saved node list.".to_string()) +} + async fn decode_response(response: reqwest::Response) -> Result { let status = response.status(); let bytes = response.bytes().await.map_err(|error| error.to_string())?; @@ -282,6 +343,13 @@ async fn open_web_app( ); } } + let registry = remember_node(&state, &base)?; + #[cfg(target_os = "macos")] + macos_titlebar::install(&webview, ®istry)?; + navigate_to_node(&state, &webview, &base) +} + +fn navigate_to_node(state: &AppState, webview: &WebviewWindow, base: &Url) -> Result<(), String> { let origin = base.origin().ascii_serialization(); let capability_id = state .notification_capability_sequence @@ -291,7 +359,7 @@ async fn open_web_app( .add_capability(capability) .map_err(|error| format!("Couldn’t enable native notifications for this node: {error}"))?; webview - .navigate(base) + .navigate(base.clone()) .map_err(|error| format!("Couldn’t open the Synapsis web app: {error}")) } @@ -309,6 +377,145 @@ fn remote_notification_capability(origin: &str, sequence: u64) -> String { .to_string() } +#[cfg(target_os = "macos")] +mod macos_titlebar { + use super::*; + use objc2::rc::Retained; + use objc2::runtime::AnyObject; + use objc2::{define_class, msg_send, sel, MainThreadOnly}; + use objc2_app_kit::{ + NSControlStateValueOn, NSLayoutAttribute, NSMenu, NSMenuItem, NSPopUpButton, + NSTitlebarAccessoryViewController, NSView, NSWindow, + }; + use objc2_foundation::{MainThreadMarker, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString}; + use std::sync::OnceLock; + + static APP_HANDLE: OnceLock = OnceLock::new(); + + define_class!( + #[unsafe(super = NSTitlebarAccessoryViewController)] + #[thread_kind = MainThreadOnly] + #[ivars = ()] + struct NodeTitlebarController; + + impl NodeTitlebarController { + #[unsafe(method(selectNode:))] + fn select_node(&self, sender: &NSMenuItem) { + let Some(value) = sender + .representedObject() + .and_then(|object| object.downcast::().ok()) + .map(|string| string.to_string()) + else { + return; + }; + let Some(app) = APP_HANDLE.get() else { + return; + }; + let Some(webview) = app.get_webview_window("main") else { + return; + }; + let state = app.state::(); + + if value == "__add_node__" { + let Ok(saved_entry_url) = state.entry_url.lock() else { + return; + }; + let Some(mut entry_url) = saved_entry_url.clone() else { + return; + }; + entry_url.set_query(Some("addNode=1")); + let _ = webview.navigate(entry_url); + return; + } + + let Ok(base) = normalize_node(&value) else { + return; + }; + let Ok(registry) = remember_node(&state, &base) else { + return; + }; + let _ = install(&webview, ®istry); + let _ = navigate_to_node(&state, &webview, &base); + } + } + + unsafe impl NSObjectProtocol for NodeTitlebarController {} + ); + + impl NodeTitlebarController { + fn new(mtm: MainThreadMarker) -> Retained { + let this = Self::alloc(mtm).set_ivars(()); + unsafe { msg_send![super(this), init] } + } + } + + pub fn initialize(app: &tauri::AppHandle) { + let _ = APP_HANDLE.set(app.clone()); + } + + pub fn install(webview: &WebviewWindow, registry: &NodeRegistry) -> Result<(), String> { + let mtm = MainThreadMarker::new() + .ok_or_else(|| "The node switcher must be created on the main thread.".to_string())?; + let controller = NodeTitlebarController::new(mtm); + let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str("Nodes")); + + for node_url in ®istry.nodes { + let Ok(url) = Url::parse(node_url) else { + continue; + }; + let title = url.host_str().unwrap_or(node_url); + let item = unsafe { + menu.addItemWithTitle_action_keyEquivalent( + &NSString::from_str(title), + Some(sel!(selectNode:)), + &NSString::new(), + ) + }; + unsafe { + item.setTarget(Some(&*controller as &AnyObject)); + item.setRepresentedObject(Some(&*NSString::from_str(node_url) as &AnyObject)); + } + if registry.active.as_deref() == Some(node_url) { + item.setState(NSControlStateValueOn); + } + } + + if !registry.nodes.is_empty() { + menu.addItem(&NSMenuItem::separatorItem(mtm)); + } + let add_item = unsafe { + menu.addItemWithTitle_action_keyEquivalent( + &NSString::from_str("Add Node…"), + Some(sel!(selectNode:)), + &NSString::new(), + ) + }; + unsafe { + add_item.setTarget(Some(&*controller as &AnyObject)); + add_item.setRepresentedObject(Some(&*NSString::from_str("__add_node__") as &AnyObject)); + } + let button = + NSPopUpButton::pullDownButtonWithTitle_menu(&NSString::from_str("Nodes"), &menu); + button.setBordered(false); + button.setFrame(NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(86.0, 28.0))); + controller.setView(unsafe { &*(&*button as *const NSPopUpButton as *const NSView) }); + controller.setLayoutAttribute(NSLayoutAttribute::Right); + controller.setAutomaticallyAdjustsSize(false); + + let window = unsafe { + let pointer = webview + .ns_window() + .map_err(|error| format!("Couldn’t access the desktop title bar: {error}"))?; + &*(pointer as *const NSWindow) + }; + while window.titlebarAccessoryViewControllers().count() > 0 { + window.removeTitlebarAccessoryViewControllerAtIndex(0); + } + window.addTitlebarAccessoryViewController(&controller); + Ok(()) + } +} + #[tauri::command] async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> { let base = normalize_node(&node_url)?; @@ -341,6 +548,41 @@ pub fn run() { client, notification_capability_sequence: AtomicU64::new(0), entry_url: Mutex::new(None), + node_registry: Mutex::new(NodeRegistry::default()), + node_registry_path: Mutex::new(None), + }) + .setup(|app| { + let webview = app + .get_webview_window("main") + .ok_or_else(|| "Couldn’t find the main Synapsis window.".to_string())?; + let registry_path = app + .path() + .app_data_dir() + .map_err(|error| format!("Couldn’t find the app data folder: {error}"))? + .join("nodes.json"); + let registry = load_node_registry(®istry_path); + let state = app.state::(); + *state + .entry_url + .lock() + .map_err(|_| "Couldn’t remember the app entry screen.".to_string())? = + Some(webview.url()?); + *state + .node_registry_path + .lock() + .map_err(|_| "Couldn’t prepare the saved node list.".to_string())? = + Some(registry_path); + *state + .node_registry + .lock() + .map_err(|_| "Couldn’t prepare the saved node list.".to_string())? = + registry.clone(); + #[cfg(target_os = "macos")] + { + macos_titlebar::initialize(app.handle()); + macos_titlebar::install(&webview, ®istry)?; + } + Ok(()) }) .on_page_load(|webview, payload| { if payload.event() != PageLoadEvent::Finished @@ -390,7 +632,8 @@ pub fn run() { register, logout, has_web_session, - open_web_app + open_web_app, + get_saved_nodes ]) .run(tauri::generate_context!()) .expect("error while running Synapsis"); diff --git a/src/App.tsx b/src/App.tsx index 68c2755..cae01c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ type Connection = { }; type AuthResponse = { user: User }; +type NodeRegistry = { nodes: string[]; active?: string }; const DEFAULT_NODE = "synapsis.social"; const SAVED_NODE_KEY = "synapsis.nodeUrl"; @@ -246,25 +247,29 @@ function App() { useEffect(() => { const launchParams = new URLSearchParams(window.location.search); - if (launchParams.has("signedOut")) { - localStorage.removeItem(SAVED_NODE_KEY); + if (launchParams.has("signedOut") || launchParams.has("addNode")) { + if (launchParams.has("signedOut")) localStorage.removeItem(SAVED_NODE_KEY); window.history.replaceState({}, "", window.location.pathname); setScreen("node"); setBootstrapping(false); return; } - const savedNode = localStorage.getItem(SAVED_NODE_KEY); - if (!savedNode || !isTauri()) { + if (!isTauri()) { setBootstrapping(false); return; } - const savedNodeUrl = savedNode; + async function restore() { + let handedOffToWebApp = false; try { + const registry = await invoke("get_saved_nodes"); + const savedNodeUrl = registry.active || localStorage.getItem(SAVED_NODE_KEY); + if (!savedNodeUrl) return; const hasWebSession = await invoke("has_web_session", { nodeUrl: savedNodeUrl }); if (hasWebSession) { await openNodeWebApp(savedNodeUrl); + handedOffToWebApp = true; return; } @@ -275,13 +280,14 @@ function App() { setUser(connection.user); if (connection.user) { await openNodeWebApp(connection.nodeUrl); + handedOffToWebApp = true; } else { setScreen("login"); } } catch { setScreen("node"); } finally { - setBootstrapping(false); + if (!handedOffToWebApp) setBootstrapping(false); } } void restore();