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:
+245
-2
@@ -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<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)]
|
||||
@@ -98,6 +109,56 @@ fn endpoint(base: &Url, path: &str) -> Result<Url, String> {
|
||||
.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<NodeRegistry, String> {
|
||||
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<NodeRegistry, String> {
|
||||
state
|
||||
.node_registry
|
||||
.lock()
|
||||
.map(|registry| registry.clone())
|
||||
.map_err(|_| "Couldn’t access the saved node list.".to_string())
|
||||
}
|
||||
|
||||
async fn decode_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, String> {
|
||||
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<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, ®istry);
|
||||
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 ®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::<AppState>();
|
||||
*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");
|
||||
|
||||
Reference in New Issue
Block a user