diff --git a/.gitignore b/.gitignore index 1b2ae933..a79024a0 100644 --- a/.gitignore +++ b/.gitignore @@ -227,6 +227,8 @@ __marimo__/ # otherwise record as gitlinks to commits no clone of this one can resolve. .claude/ -# The macOS app: what pnpm installs, and the schemas tauri-build writes on every build. +# The macOS app: what pnpm installs, and the schemas and the permissions of its own commands +# tauri-build writes on every build. desktop/node_modules/ desktop/src-tauri/gen/ +desktop/src-tauri/permissions/autogenerated/ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 10e7203b..b485f2df 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1477,10 +1477,14 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" name = "humanize" version = "0.0.0" dependencies = [ + "block2", "flate2", "libc", "log", + "objc2", "objc2-core-foundation", + "objc2-foundation", + "objc2-user-notifications", "semver", "serde", "serde_json", @@ -2366,6 +2370,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.2", + "block2", "objc2", "objc2-foundation", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 93aedca7..0eb718c8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -27,6 +27,11 @@ libc = "0.2" log = "0.4" # The update's alerts: CFUserNotification, which never holds up a quit as a sheet would. objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFDate", "CFDictionary", "CFString", "CFURL", "CFUserNotification"] } +# Notifications the page asks for: UNUserNotificationCenter, and a delegate told of a click. +block2 = { version = "0.6", default-features = false, features = ["std"] } +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSError", "NSObject", "NSString"] } +objc2-user-notifications = { version = "0.3", default-features = false, features = ["std", "bitflags", "block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSound", "UNNotificationTrigger", "UNUserNotificationCenter"] } semver = "1" serde = { version = "1", features = ["derive"] } # What generate_context! writes the updater's configuration out with. diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 261851f6..235af997 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,9 @@ +/// The app's commands, each granted by a capability: `notify` to `hmz web`'s page, the rest to +/// the app's own. +const COMMANDS: &[&str] = &["status", "retry", "keep", "update_now", "notify"]; + fn main() { - tauri_build::build(); + let manifest = tauri_build::AppManifest::new().commands(COMMANDS); + tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) + .expect("failed to run the Tauri build script"); } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 818e7b8b..02e0605e 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,7 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's own page: its own commands need no permission, and it is granted only to drag and zoom the window.", + "description": "The app's own page: granted its own commands, which say and change how hmz web starts, and to drag and zoom the window.", "windows": ["main"], - "permissions": ["core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"] + "permissions": [ + "allow-status", + "allow-retry", + "allow-keep", + "allow-update-now", + "core:window:allow-start-dragging", + "core:window:allow-internal-toggle-maximize" + ] } diff --git a/desktop/src-tauri/capabilities/page.json b/desktop/src-tauri/capabilities/page.json index ef5c64cf..14239428 100644 --- a/desktop/src-tauri/capabilities/page.json +++ b/desktop/src-tauri/capabilities/page.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "page", - "description": "hmz web's page, on whichever port of this machine the app started it, the window loading no other: granted only to drag and zoom the window by its top row, and none of the app's commands.", + "description": "hmz web's page, on whichever port of this machine the app started it, the window loading no other: granted only to post a notification, and to drag and zoom the window by its top row.", "windows": ["main"], "remote": { "urls": ["http://127.0.0.1:*"] }, - "permissions": ["core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"] + "permissions": ["allow-notify", "core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ba491b4b..3fecf5da 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod child; mod chrome; mod install; mod menu; +mod notify; mod server; mod shell_env; mod update; @@ -92,6 +93,8 @@ struct Shell { page: Mutex>, /// An epic's archive opened before then, imported once it is set. opening: Mutex>, + /// The address of a notification clicked before then, opened once it is set. + notified: Mutex>, } /// Where `hmz web` stands, once it is anything but starting, and other than the page drew last. @@ -106,6 +109,13 @@ async fn status(after: Option, shell: State<'_, Shell>) -> Result) { + notify::post(&title, &body, address.as_deref().unwrap_or_default()); +} + /// Starts again where it stopped short: installing `hmz`, then `hmz web`. #[tauri::command] fn retry(app: AppHandle, shell: State<'_, Shell>) { @@ -205,7 +215,9 @@ fn start(app: AppHandle, how: How) { *lock(&shell.page) = Some(page.clone()); let opening = lock(&shell.opening).take(); let opening = opening.and_then(|path| importing(&page, &path)); - show(app, opening.unwrap_or(page)); + let notified = lock(&shell.notified).take(); + let notified = notified.and_then(|address| notify::target(&page, &address)); + show(app, opening.or(notified).unwrap_or(page)); }); // Told once `hmz web` stops on its own; nothing where the app stopped it. if let Some(said) = server.exited() { @@ -454,12 +466,16 @@ pub fn run() { downloads: Mutex::default(), page: Mutex::default(), opening: Mutex::default(), + notified: Mutex::default(), }) .menu(menu::build) .on_menu_event(menu::handle) - .invoke_handler(tauri::generate_handler![status, retry, keep, update_now]) + .invoke_handler(tauri::generate_handler![ + status, retry, keep, update_now, notify + ]) .setup(|app| { window(app.handle())?; + notify::init(app.handle()); start(app.handle().clone(), How::Usual); update::init(app.handle())?; Ok(()) diff --git a/desktop/src-tauri/src/notify.rs b/desktop/src-tauri/src/notify.rs new file mode 100644 index 00000000..83723686 --- /dev/null +++ b/desktop/src-tauri/src/notify.rs @@ -0,0 +1,262 @@ +//! A notification as macOS posts one for an app, through `UNUserNotificationCenter`: what +//! `hmz web`'s page asks for when an epic ends, a flow asks, or a chat turn is done while the +//! window is not looked at. Clicking one brings the window forward, and opens on `hmz web`'s +//! page the address it was given -- a hash of that page, and never anywhere else. + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use block2::{DynBlock, RcBlock}; +use objc2::rc::Retained; +use objc2::runtime::{Bool, NSObject, NSObjectProtocol, ProtocolObject}; +use objc2::{AnyThread, define_class, msg_send}; +use objc2_foundation::{NSError, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNMutableNotificationContent, UNNotification, + UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse, + UNNotificationSound, UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::AppHandle; +use url::Url; + +use crate::{lock, on_main, reveal, show}; + +/// The longest address a notification opens: one of the page's own is far shorter. +const LONGEST: usize = 2048; + +/// The app, for a notification clicked. +static APP: OnceLock = OnceLock::new(); + +/// Notifications posted this launch, numbering each apart from the others. +static POSTED: AtomicU64 = AtomicU64::new(0); + +define_class!( + // SAFETY: NSObject has no subclassing requirements, and the delegate keeps no state. + #[unsafe(super(NSObject))] + #[name = "HumanizeNotifications"] + struct Delegate; + + unsafe impl NSObjectProtocol for Delegate {} + + unsafe impl UNUserNotificationCenterDelegate for Delegate { + /// Shown even while the app is in front: the page asks only while its window is not + /// looked at. + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present( + &self, + _center: &UNUserNotificationCenter, + _notification: &UNNotification, + handler: &DynBlock, + ) { + handler.call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List + | UNNotificationPresentationOptions::Sound,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + handler: &DynBlock, + ) { + let identifier = response.notification().request().identifier().to_string(); + clicked(address(&identifier).to_owned()); + handler.call(()); + } + } +); + +/// Listens for notifications clicked, those of an earlier launch too. On the main thread, as +/// the app sets up. +pub fn init(app: &AppHandle) { + let _ = APP.set(app.clone()); + if !bundled() { + return; + } + // SAFETY: NSObject's own init, on an instance just allocated. + let delegate: Retained = unsafe { msg_send![Delegate::alloc(), init] }; + UNUserNotificationCenter::currentNotificationCenter() + .setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + // The centre holds its delegate weakly: this one is kept for as long as the app runs. + std::mem::forget(delegate); +} + +/// Posts a notification, asking the first time whether the app may. +pub fn post(title: &str, body: &str, address: &str) { + // Kept with the notification for as long as macOS keeps it: only what a click may open. + let address = hash(address).map_or("", |_| address); + if !bundled() { + log::info!("a notification is not posted outside the app's bundle"); + return; + } + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + content.setBody(&NSString::from_str(body)); + content.setSound(Some(&UNNotificationSound::defaultSound())); + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(&identifier(address)), + &content, + None, + ); + let center = UNUserNotificationCenter::currentNotificationCenter(); + let posting = center.clone(); + // Asked once: macOS answers at once from then on, as the user last said. + let posted = RcBlock::new(|error: *mut NSError| { + // SAFETY: an NSError the centre made and holds through the call, or none. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!("a notification could not be posted: {error}"); + } + }); + let granted = RcBlock::new(move |granted: Bool, error: *mut NSError| { + // SAFETY: as above. + if let Some(error) = unsafe { error.as_ref() } { + log::warn!("notifications could not be asked for: {error}"); + } else if granted.as_bool() { + posting.addNotificationRequest_withCompletionHandler(&request, Some(&posted)); + } else { + log::info!("notifications are not allowed"); + } + }); + center.requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &granted, + ); +} + +/// Brings the window forward, on `hmz web`'s page at the address clicked where it is one: on +/// the page it shows, by its hash alone; or, where it shows none yet -- a click that launched +/// the app -- on the page it opens once there is one. +fn clicked(address: String) { + let Some(app) = APP.get() else { return }; + on_main(app, move |app, shell| { + let page = lock(&shell.page).clone(); + match page { + Some(mut page) => { + // Signed in already: the hash changes, and the page is not loaded again. + page.set_query(None); + if let Some(url) = target(&page, &address) { + show(app, url); + } + } + None => *lock(&shell.notified) = Some(address), + } + reveal(app); + }); +} + +/// Whether the app runs from its bundle, `….app/Contents/MacOS/…`: the notification centre +/// throws for a process that is not, as one `cargo` runs. +fn bundled() -> bool { + std::env::current_exe().is_ok_and(|exe| in_bundle(&exe)) +} + +fn in_bundle(exe: &std::path::Path) -> bool { + let mut up = exe.ancestors().skip(1); + up.next().and_then(|dir| dir.file_name()) == Some("MacOS".as_ref()) + && up.next().and_then(|dir| dir.file_name()) == Some("Contents".as_ref()) + && up + .next() + .and_then(|dir| dir.extension()) + .is_some_and(|ext| ext == "app") +} + +/// A notification's identifier: unique, so that none replaces another, and carrying the address +/// it opens, so that one of an earlier launch opens it too. +fn identifier(address: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let n = POSTED.fetch_add(1, Ordering::Relaxed); + format!("{now}.{n} {address}") +} + +/// The address a notification's identifier carries. +fn address(identifier: &str) -> &str { + identifier + .split_once(' ') + .map_or("", |(_, address)| address) +} + +/// The hash a notification may open, `#/…`, without its `#`; nothing for anything else. +fn hash(address: &str) -> Option<&str> { + address.strip_prefix('#').filter(|hash| { + hash.starts_with('/') && address.len() <= LONGEST && !hash.chars().any(char::is_control) + }) +} + +/// Where a notification clicked opens: the hash it was given, `#/…`, at the root of `hmz web`'s +/// page -- its origin, and its key where it is given one -- and nowhere else; nothing for +/// anything but such a hash. +pub fn target(page: &Url, address: &str) -> Option { + let hash = hash(address)?; + let mut url = page.clone(); + url.set_path("/"); + url.set_fragment(Some(hash)); + Some(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opens_a_hash_of_the_servers_page_alone() { + let server = Url::parse("http://127.0.0.1:8765/").unwrap(); + let at = |address: &str| target(&server, address).map(String::from); + assert_eq!( + at("#/@gpu/live?workspace=%2Fhome%2Fme").as_deref(), + Some("http://127.0.0.1:8765/#/@gpu/live?workspace=%2Fhome%2Fme") + ); + assert_eq!(at("#/").as_deref(), Some("http://127.0.0.1:8765/#/")); + // From the address with the key, as the window first opens on, keeping it to sign in. + let first = Url::parse("http://127.0.0.1:8765/?key=secret#/epics").unwrap(); + assert_eq!( + target(&first, "#/@local/live").map(String::from).as_deref(), + Some("http://127.0.0.1:8765/?key=secret#/@local/live") + ); + // Never another origin, path or scheme. + for address in [ + "", + "#", + "#epics", + "/#/epics", + "epics", + "http://evil.example/#/epics", + "//evil.example/#/epics", + "javascript:alert(1)", + "#/epics\n", + "#/\u{7f}", + ] { + assert_eq!(at(address), None, "{address:?}"); + } + assert_eq!(at(&format!("#/{}", "a".repeat(LONGEST))), None); + // Whatever the hash says, the origin stays the server's. + let odd = target(&server, "#/x#@evil.example:80/ y").unwrap(); + assert_eq!(odd.origin(), server.origin()); + assert_eq!(odd.path(), "/"); + assert_eq!(odd.query(), None); + } + + #[test] + fn carries_its_address_in_a_unique_identifier() { + let one = identifier("#/@local/live?workspace=a b"); + let two = identifier("#/@local/live?workspace=a b"); + assert_ne!(one, two); + assert_eq!(address(&one), "#/@local/live?workspace=a b"); + assert_eq!(address(&identifier("")), ""); + assert_eq!(address("elsewhere"), ""); + } + + #[test] + fn posts_only_from_its_bundle() { + let at = |path: &str| in_bundle(std::path::Path::new(path)); + assert!(at("/Applications/humanize.app/Contents/MacOS/humanize")); + assert!(!at("/repo/desktop/src-tauri/target/debug/humanize")); + assert!(!at("/x/Contents/MacOS/humanize")); + assert!(!at("/x.app/MacOS/humanize")); + assert!(!at("humanize")); + } +} diff --git a/docs/user/desktop.md b/docs/user/desktop.md index 75f28a5d..8ab5156d 100644 --- a/docs/user/desktop.md +++ b/docs/user/desktop.md @@ -58,6 +58,19 @@ The app is a window, not a second humanize. It runs `hmz` as - **Links to elsewhere open in your browser**, and an export is saved to **Downloads** and shown in the Finder. - **It updates itself.** See [Updates](#updates). +- **It notifies you as a Mac app does.** When an epic ends, a flow asks you something, or a chat + turn is done while you are not looking at the window, the page's notification shows in the + corner of the screen and in Notification Center. See [Notifications](#notifications). + +## Notifications {#notifications} + +The page's [notifications](/user/web#notifications) come from the app itself, so they +look and stay as any Mac app's do. The first one asks whether humanize may send them: choose +**Allow**. To change that later, open **System Settings → Notifications → humanize**. + +Clicking one brings the window forward, on what it is about: the epic that ended, or the daemon +whose flow asked. Which kinds you are told of is set on the page, in **Settings → +Notifications**. ## Opening a `.hmzepic` {#opening-a-hmzepic}