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:
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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");
|
||||
|
||||
+12
-6
@@ -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<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 });
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user