From 5425d83ee1bdf0580d76d9a993fb302c9de04ca0 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 19:13:18 +0200 Subject: [PATCH] Fix blogmark deep-link delivery and editor activation --- Cargo.lock | 24 ++++++ crates/bds-ui/Cargo.toml | 2 +- crates/bds-ui/src/app.rs | 69 ++++++++++++++- crates/bds-ui/src/platform/macos.rs | 127 +++++++++++++++++----------- 4 files changed, 170 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 457ddd8..ccbef4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6280,6 +6280,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -6344,6 +6356,7 @@ dependencies = [ "libc", "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-services", ] [[package]] @@ -6406,6 +6419,17 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-symbols" version = "0.2.2" diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 52cf1f9..b33f82c 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -24,7 +24,7 @@ wry = "0.55.1" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-foundation = "0.3" +objc2-foundation = { version = "0.3", features = ["objc2-core-services"] } objc2-app-kit = "0.3" [dev-dependencies] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 81ad49f..3a9e011 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -956,6 +956,10 @@ pub struct BdsApp { _menu_bar: Option, menu_registry: MenuRegistry, native_edit_commands: native_edit::EditCommandQueue, + #[cfg(target_os = "macos")] + _lifecycle_handler: Option>, + #[cfg(target_os = "macos")] + lifecycle_receiver: Option>, // i18n ui_locale: UiLocale, @@ -1105,6 +1109,11 @@ impl BdsApp { .as_ref() .and_then(|db| engine::chat::list_conversations(db.conn()).ok()) .unwrap_or_default(); + #[cfg(target_os = "macos")] + let (lifecycle_handler, lifecycle_receiver) = + crate::platform::macos::install_lifecycle_handler() + .map(|(handler, receiver)| (Some(handler), Some(receiver))) + .unwrap_or((None, None)); ( Self { @@ -1153,6 +1162,10 @@ impl BdsApp { _menu_bar: Some(menu_bar), menu_registry: registry, native_edit_commands: native_edit::command_queue(), + #[cfg(target_os = "macos")] + _lifecycle_handler: lifecycle_handler, + #[cfg(target_os = "macos")] + lifecycle_receiver, ui_locale: locale, content_language: "en".to_string(), blog_languages: Vec::new(), @@ -1250,6 +1263,10 @@ impl BdsApp { _menu_bar: None, menu_registry: MenuRegistry::empty(), native_edit_commands: native_edit::command_queue(), + #[cfg(target_os = "macos")] + _lifecycle_handler: None, + #[cfg(target_os = "macos")] + lifecycle_receiver: None, ui_locale: UiLocale::En, content_language: "en".to_string(), blog_languages: Vec::new(), @@ -2423,6 +2440,15 @@ impl BdsApp { .into_iter() .map(|action| self.dispatch_menu_action(action)) .collect::>(); + #[cfg(target_os = "macos")] + if let Some(receiver) = &self.lifecycle_receiver { + tasks.push( + crate::platform::macos::drain_lifecycle(receiver) + .into_iter() + .map(Task::done) + .fold(Task::none(), Task::chain), + ); + } tasks.push(self.reload_changed_documentation()); Task::batch(tasks) } @@ -2576,6 +2602,8 @@ impl BdsApp { for error in result.transform_errors { self.add_output(&error); } + self.sidebar_view = SidebarView::Posts; + self.sidebar_visible = true; let tab = Tab { id: result.post.id.clone(), tab_type: TabType::Post, @@ -9446,6 +9474,7 @@ mod tests { use crate::i18n::t; use crate::platform::menu::MenuAction; use crate::state::ToastLevel; + use crate::state::navigation::SidebarView; use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::state::tabs::{Tab, TabType}; use crate::views::chat_view::ChatEditorState; @@ -9463,7 +9492,9 @@ mod tests { use bds_core::db::queries::project::insert_project; use bds_core::engine::generation::GenerationReport; use bds_core::engine::task::{TaskStatus, TaskStatus::*}; - use bds_core::engine::{ai, chat, media, menu, meta, post, script, template, wordpress_import}; + use bds_core::engine::{ + ai, blogmark, chat, media, menu, meta, post, script, template, wordpress_import, + }; use bds_core::i18n::UiLocale; use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind}; use chrono::{Datelike, TimeZone}; @@ -9562,6 +9593,42 @@ mod tests { assert!(!remote_error_closes_connection("engine_error")); } + #[test] + fn imported_blogmark_activates_posts_and_opens_its_editor() { + let (db, project, temp) = setup(); + let created = post::create_post( + db.conn(), + temp.path(), + &project.id, + "Saved From Browser", + Some("[Saved From Browser](https://example.com/)"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf()); + app.sidebar_view = SidebarView::Settings; + app.sidebar_visible = false; + let task_id = app.task_manager.submit("Importing blogmark"); + + let _ = app.update(Message::BlogmarkImported { + task_id, + result: Ok(blogmark::BlogmarkImportResult { + post: created.clone(), + toasts: Vec::new(), + transform_errors: Vec::new(), + }), + }); + + assert_eq!(app.sidebar_view, SidebarView::Posts); + assert!(app.sidebar_visible); + assert_eq!(app.active_tab.as_deref(), Some(created.id.as_str())); + assert!(app.post_editors.contains_key(&created.id)); + } + #[test] fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() { let (db, project, temp) = setup(); diff --git a/crates/bds-ui/src/platform/macos.rs b/crates/bds-ui/src/platform/macos.rs index 85bf8e7..dc17cf7 100644 --- a/crates/bds-ui/src/platform/macos.rs +++ b/crates/bds-ui/src/platform/macos.rs @@ -2,10 +2,8 @@ use std::path::PathBuf; use std::sync::mpsc; use objc2::rc::Retained; -use objc2::runtime::ProtocolObject; -use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; -use objc2_app_kit::{NSApplication, NSApplicationDelegate}; -use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL}; +use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2_foundation::{NSAppleEventDescriptor, NSAppleEventManager, NSObject, NSObjectProtocol}; use crate::app::Message; @@ -16,81 +14,83 @@ pub enum LifecycleEvent { UrlOpen(String), } -/// Ivars for the delegate — holds the sender side of the lifecycle channel. +/// Ivars for the Apple-event handler — holds the lifecycle sender. #[derive(Debug)] -pub struct DelegateIvars { +pub struct HandlerIvars { tx: mpsc::Sender, } define_class!( - // SAFETY: NSObject has no subclassing requirements. BdsAppDelegate does not impl Drop. + // SAFETY: NSObject has no subclassing requirements. BdsAppleEventHandler does not impl Drop. #[unsafe(super(NSObject))] #[thread_kind = MainThreadOnly] - #[name = "BdsAppDelegate"] - #[ivars = DelegateIvars] - pub struct BdsAppDelegate; + #[name = "BdsAppleEventHandler"] + #[ivars = HandlerIvars] + pub struct BdsAppleEventHandler; // SAFETY: NSObjectProtocol declares no additional safety requirements. - unsafe impl NSObjectProtocol for BdsAppDelegate {} + unsafe impl NSObjectProtocol for BdsAppleEventHandler {} - // SAFETY: Every exported selector below uses the exact AppKit delegate signature and the - // class is main-thread-only, as required by NSApplicationDelegate. - unsafe impl NSApplicationDelegate for BdsAppDelegate { - #[unsafe(method(applicationDidFinishLaunching:))] - fn did_finish_launching(&self, _notification: &NSNotification) { - // App is already running via Iced; nothing to do here. - } - - #[unsafe(method(application:openFile:))] - fn application_open_file(&self, _sender: &NSApplication, filename: &NSString) -> bool { - let path = PathBuf::from(filename.to_string()); - let _ = self.ivars().tx.send(LifecycleEvent::FileOpen(path)); - true - } - - #[unsafe(method(application:openURLs:))] - fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray) { - for i in 0..urls.len() { - if let Some(url) = urls.objectAtIndex(i).absoluteString() { - let _ = self - .ivars() - .tx - .send(LifecycleEvent::UrlOpen(url.to_string())); - } + impl BdsAppleEventHandler { + #[unsafe(method(handleGetURLEvent:withReplyEvent:))] + fn handle_get_url_event( + &self, + event: &NSAppleEventDescriptor, + _reply: &NSAppleEventDescriptor, + ) { + if let Some(url) = event + .paramDescriptorForKeyword(four_char_code(*b"----")) + .and_then(|descriptor| descriptor.stringValue()) + { + let _ = self + .ivars() + .tx + .send(LifecycleEvent::UrlOpen(url.to_string())); } } } ); -impl BdsAppDelegate { +impl BdsAppleEventHandler { fn new(mtm: MainThreadMarker, tx: mpsc::Sender) -> Retained { let this = Self::alloc(mtm); - let this = this.set_ivars(DelegateIvars { tx }); + let this = this.set_ivars(HandlerIvars { tx }); // SAFETY: `this` has initialized ivars and NSObject's `init` accepts ownership of the // allocated receiver, returning the retained initialized object. unsafe { msg_send![super(this), init] } } } -/// Create the macOS lifecycle channel and wire the native delegate. -/// -/// Returns `(Retained, Receiver)`. The delegate must be kept alive -/// for the lifetime of the application (drop it and the callbacks stop). +const fn four_char_code(bytes: [u8; 4]) -> u32 { + u32::from_be_bytes(bytes) +} + pub fn lifecycle_channel() -> (mpsc::Sender, mpsc::Receiver) { mpsc::channel() } -/// Install the native Objective-C delegate on NSApplication, wiring it to the given sender. +/// Install a Get URL Apple-event handler without replacing Winit's application delegate. /// -/// Must be called from the main thread after the Iced application is running. -/// Returns the retained delegate (caller must keep it alive). -pub fn install_delegate(tx: mpsc::Sender) -> Option> { +/// Must be called after Iced has created its event loop. The returned handler must remain alive. +pub fn install_lifecycle_handler() -> Option<( + Retained, + mpsc::Receiver, +)> { let mtm = MainThreadMarker::new()?; - let delegate = BdsAppDelegate::new(mtm, tx); - let app = NSApplication::sharedApplication(mtm); - let object = ProtocolObject::from_ref(&*delegate); - app.setDelegate(Some(object)); - Some(delegate) + let (tx, receiver) = lifecycle_channel(); + let handler = BdsAppleEventHandler::new(mtm, tx); + let manager = NSAppleEventManager::sharedAppleEventManager(); + // SAFETY: The selector is implemented above with the two Apple-event descriptor arguments + // required by NSAppleEventManager. `handler` is retained by the caller for the app lifetime. + unsafe { + manager.setEventHandler_andSelector_forEventClass_andEventID( + &handler, + sel!(handleGetURLEvent:withReplyEvent:), + four_char_code(*b"GURL"), + four_char_code(*b"GURL"), + ); + } + Some((handler, receiver)) } /// Poll the macOS lifecycle receiver for the next event and map to a Message. @@ -101,3 +101,30 @@ pub fn poll_lifecycle(receiver: &mpsc::Receiver) -> Option None, } } + +pub fn drain_lifecycle(receiver: &mpsc::Receiver) -> Vec { + std::iter::from_fn(|| poll_lifecycle(receiver)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drains_cold_start_events_in_arrival_order() { + let (sender, receiver) = lifecycle_channel(); + sender + .send(LifecycleEvent::UrlOpen("ruds://new-post?title=One".into())) + .unwrap(); + sender + .send(LifecycleEvent::UrlOpen("ruds://new-post?title=Two".into())) + .unwrap(); + + let messages = drain_lifecycle(&receiver); + assert!(matches!( + &messages[..], + [Message::UrlOpenRequested(one), Message::UrlOpenRequested(two)] + if one.ends_with("One") && two.ends_with("Two") + )); + } +}