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
This commit is contained in:
2026-07-15 17:14:48 -07:00
committed by Hop
parent 86d41be917
commit 016fcc6c94
4 changed files with 300 additions and 8 deletions
+25
View File
@@ -2267,9 +2267,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core",
] ]
[[package]] [[package]]
@@ -2289,6 +2297,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [ dependencies = [
"bitflags 2.13.1",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2349,6 +2358,19 @@ dependencies = [
"objc2-core-graphics", "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]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@@ -3677,6 +3699,9 @@ name = "synapsis-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"http", "http",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
+18
View File
@@ -24,5 +24,23 @@ serde_json = "1"
reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] } reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] }
tauri-plugin-notification = "2" 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] [dev-dependencies]
http = "1" http = "1"
+245 -2
View File
@@ -1,5 +1,7 @@
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::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
@@ -10,6 +12,15 @@ struct AppState {
client: Client, client: Client,
notification_capability_sequence: AtomicU64, notification_capability_sequence: AtomicU64,
entry_url: Mutex<Option<Url>>, entry_url: Mutex<Option<Url>>,
node_registry: Mutex<NodeRegistry>,
node_registry_path: Mutex<Option<PathBuf>>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NodeRegistry {
nodes: Vec<String>,
active: Option<String>,
} }
#[derive(Debug, Default, Deserialize, Serialize)] #[derive(Debug, Default, Deserialize, Serialize)]
@@ -98,6 +109,56 @@ fn endpoint(base: &Url, path: &str) -> Result<Url, String> {
.map_err(|_| "The selected node address is invalid.".into()) .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(|_| "Couldnt 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!("Couldnt save the node list: {error}"))?;
}
let bytes = serde_json::to_vec_pretty(registry)
.map_err(|error| format!("Couldnt encode the node list: {error}"))?;
fs::write(path, bytes).map_err(|error| format!("Couldnt save the node list: {error}"))
}
fn remember_node(state: &AppState, node: &Url) -> Result<NodeRegistry, String> {
let node_url = node.as_str().to_owned();
let snapshot = {
let mut registry = state
.node_registry
.lock()
.map_err(|_| "Couldnt 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<NodeRegistry, String> {
state
.node_registry
.lock()
.map(|registry| registry.clone())
.map_err(|_| "Couldnt access the saved node list.".to_string())
}
async fn decode_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, String> { async fn decode_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, String> {
let status = response.status(); let status = response.status();
let bytes = response.bytes().await.map_err(|error| error.to_string())?; 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, &registry)?;
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 origin = base.origin().ascii_serialization();
let capability_id = state let capability_id = state
.notification_capability_sequence .notification_capability_sequence
@@ -291,7 +359,7 @@ async fn open_web_app(
.add_capability(capability) .add_capability(capability)
.map_err(|error| format!("Couldnt enable native notifications for this node: {error}"))?; .map_err(|error| format!("Couldnt enable native notifications for this node: {error}"))?;
webview webview
.navigate(base) .navigate(base.clone())
.map_err(|error| format!("Couldnt open the Synapsis web app: {error}")) .map_err(|error| format!("Couldnt open the Synapsis web app: {error}"))
} }
@@ -309,6 +377,145 @@ fn remote_notification_capability(origin: &str, sequence: u64) -> String {
.to_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<tauri::AppHandle> = 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::<NSString>().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::<AppState>();
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, &registry);
let _ = navigate_to_node(&state, &webview, &base);
}
}
unsafe impl NSObjectProtocol for NodeTitlebarController {}
);
impl NodeTitlebarController {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
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 &registry.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!("Couldnt access the desktop title bar: {error}"))?;
&*(pointer as *const NSWindow)
};
while window.titlebarAccessoryViewControllers().count() > 0 {
window.removeTitlebarAccessoryViewControllerAtIndex(0);
}
window.addTitlebarAccessoryViewController(&controller);
Ok(())
}
}
#[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)?;
@@ -341,6 +548,41 @@ pub fn run() {
client, client,
notification_capability_sequence: AtomicU64::new(0), notification_capability_sequence: AtomicU64::new(0),
entry_url: Mutex::new(None), 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(|| "Couldnt find the main Synapsis window.".to_string())?;
let registry_path = app
.path()
.app_data_dir()
.map_err(|error| format!("Couldnt find the app data folder: {error}"))?
.join("nodes.json");
let registry = load_node_registry(&registry_path);
let state = app.state::<AppState>();
*state
.entry_url
.lock()
.map_err(|_| "Couldnt remember the app entry screen.".to_string())? =
Some(webview.url()?);
*state
.node_registry_path
.lock()
.map_err(|_| "Couldnt prepare the saved node list.".to_string())? =
Some(registry_path);
*state
.node_registry
.lock()
.map_err(|_| "Couldnt prepare the saved node list.".to_string())? =
registry.clone();
#[cfg(target_os = "macos")]
{
macos_titlebar::initialize(app.handle());
macos_titlebar::install(&webview, &registry)?;
}
Ok(())
}) })
.on_page_load(|webview, payload| { .on_page_load(|webview, payload| {
if payload.event() != PageLoadEvent::Finished if payload.event() != PageLoadEvent::Finished
@@ -390,7 +632,8 @@ pub fn run() {
register, register,
logout, logout,
has_web_session, has_web_session,
open_web_app open_web_app,
get_saved_nodes
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Synapsis"); .expect("error while running Synapsis");
+12 -6
View File
@@ -27,6 +27,7 @@ type Connection = {
}; };
type AuthResponse = { user: User }; type AuthResponse = { user: User };
type NodeRegistry = { nodes: string[]; active?: string };
const DEFAULT_NODE = "synapsis.social"; const DEFAULT_NODE = "synapsis.social";
const SAVED_NODE_KEY = "synapsis.nodeUrl"; const SAVED_NODE_KEY = "synapsis.nodeUrl";
@@ -246,25 +247,29 @@ function App() {
useEffect(() => { useEffect(() => {
const launchParams = new URLSearchParams(window.location.search); const launchParams = new URLSearchParams(window.location.search);
if (launchParams.has("signedOut")) { if (launchParams.has("signedOut") || launchParams.has("addNode")) {
localStorage.removeItem(SAVED_NODE_KEY); if (launchParams.has("signedOut")) localStorage.removeItem(SAVED_NODE_KEY);
window.history.replaceState({}, "", window.location.pathname); window.history.replaceState({}, "", window.location.pathname);
setScreen("node"); setScreen("node");
setBootstrapping(false); setBootstrapping(false);
return; return;
} }
const savedNode = localStorage.getItem(SAVED_NODE_KEY); if (!isTauri()) {
if (!savedNode || !isTauri()) {
setBootstrapping(false); setBootstrapping(false);
return; return;
} }
const savedNodeUrl = savedNode;
async function restore() { async function restore() {
let handedOffToWebApp = false;
try { try {
const registry = await invoke<NodeRegistry>("get_saved_nodes");
const savedNodeUrl = registry.active || localStorage.getItem(SAVED_NODE_KEY);
if (!savedNodeUrl) return;
const hasWebSession = await invoke<boolean>("has_web_session", { nodeUrl: savedNodeUrl }); const hasWebSession = await invoke<boolean>("has_web_session", { nodeUrl: savedNodeUrl });
if (hasWebSession) { if (hasWebSession) {
await openNodeWebApp(savedNodeUrl); await openNodeWebApp(savedNodeUrl);
handedOffToWebApp = true;
return; return;
} }
@@ -275,13 +280,14 @@ function App() {
setUser(connection.user); setUser(connection.user);
if (connection.user) { if (connection.user) {
await openNodeWebApp(connection.nodeUrl); await openNodeWebApp(connection.nodeUrl);
handedOffToWebApp = true;
} else { } else {
setScreen("login"); setScreen("login");
} }
} catch { } catch {
setScreen("node"); setScreen("node");
} finally { } finally {
setBootstrapping(false); if (!handedOffToWebApp) setBootstrapping(false);
} }
} }
void restore(); void restore();