Fix blogmark deep-link delivery and editor activation
This commit is contained in:
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -6280,6 +6280,18 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-core-text"
|
name = "objc2-core-text"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -6344,6 +6356,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-services",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6406,6 +6419,17 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.2",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-symbols"
|
name = "objc2-symbols"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ wry = "0.55.1"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-foundation = "0.3"
|
objc2-foundation = { version = "0.3", features = ["objc2-core-services"] }
|
||||||
objc2-app-kit = "0.3"
|
objc2-app-kit = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -956,6 +956,10 @@ pub struct BdsApp {
|
|||||||
_menu_bar: Option<muda::Menu>,
|
_menu_bar: Option<muda::Menu>,
|
||||||
menu_registry: MenuRegistry,
|
menu_registry: MenuRegistry,
|
||||||
native_edit_commands: native_edit::EditCommandQueue,
|
native_edit_commands: native_edit::EditCommandQueue,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
_lifecycle_handler: Option<objc2::rc::Retained<crate::platform::macos::BdsAppleEventHandler>>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
lifecycle_receiver: Option<std::sync::mpsc::Receiver<crate::platform::macos::LifecycleEvent>>,
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
ui_locale: UiLocale,
|
ui_locale: UiLocale,
|
||||||
@@ -1105,6 +1109,11 @@ impl BdsApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
|
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
|
||||||
.unwrap_or_default();
|
.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 {
|
Self {
|
||||||
@@ -1153,6 +1162,10 @@ impl BdsApp {
|
|||||||
_menu_bar: Some(menu_bar),
|
_menu_bar: Some(menu_bar),
|
||||||
menu_registry: registry,
|
menu_registry: registry,
|
||||||
native_edit_commands: native_edit::command_queue(),
|
native_edit_commands: native_edit::command_queue(),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
_lifecycle_handler: lifecycle_handler,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
lifecycle_receiver,
|
||||||
ui_locale: locale,
|
ui_locale: locale,
|
||||||
content_language: "en".to_string(),
|
content_language: "en".to_string(),
|
||||||
blog_languages: Vec::new(),
|
blog_languages: Vec::new(),
|
||||||
@@ -1250,6 +1263,10 @@ impl BdsApp {
|
|||||||
_menu_bar: None,
|
_menu_bar: None,
|
||||||
menu_registry: MenuRegistry::empty(),
|
menu_registry: MenuRegistry::empty(),
|
||||||
native_edit_commands: native_edit::command_queue(),
|
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,
|
ui_locale: UiLocale::En,
|
||||||
content_language: "en".to_string(),
|
content_language: "en".to_string(),
|
||||||
blog_languages: Vec::new(),
|
blog_languages: Vec::new(),
|
||||||
@@ -2423,6 +2440,15 @@ impl BdsApp {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|action| self.dispatch_menu_action(action))
|
.map(|action| self.dispatch_menu_action(action))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
#[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());
|
tasks.push(self.reload_changed_documentation());
|
||||||
Task::batch(tasks)
|
Task::batch(tasks)
|
||||||
}
|
}
|
||||||
@@ -2576,6 +2602,8 @@ impl BdsApp {
|
|||||||
for error in result.transform_errors {
|
for error in result.transform_errors {
|
||||||
self.add_output(&error);
|
self.add_output(&error);
|
||||||
}
|
}
|
||||||
|
self.sidebar_view = SidebarView::Posts;
|
||||||
|
self.sidebar_visible = true;
|
||||||
let tab = Tab {
|
let tab = Tab {
|
||||||
id: result.post.id.clone(),
|
id: result.post.id.clone(),
|
||||||
tab_type: TabType::Post,
|
tab_type: TabType::Post,
|
||||||
@@ -9446,6 +9474,7 @@ mod tests {
|
|||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
use crate::platform::menu::MenuAction;
|
use crate::platform::menu::MenuAction;
|
||||||
use crate::state::ToastLevel;
|
use crate::state::ToastLevel;
|
||||||
|
use crate::state::navigation::SidebarView;
|
||||||
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
|
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
|
||||||
use crate::state::tabs::{Tab, TabType};
|
use crate::state::tabs::{Tab, TabType};
|
||||||
use crate::views::chat_view::ChatEditorState;
|
use crate::views::chat_view::ChatEditorState;
|
||||||
@@ -9463,7 +9492,9 @@ mod tests {
|
|||||||
use bds_core::db::queries::project::insert_project;
|
use bds_core::db::queries::project::insert_project;
|
||||||
use bds_core::engine::generation::GenerationReport;
|
use bds_core::engine::generation::GenerationReport;
|
||||||
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
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::i18n::UiLocale;
|
||||||
use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind};
|
use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind};
|
||||||
use chrono::{Datelike, TimeZone};
|
use chrono::{Datelike, TimeZone};
|
||||||
@@ -9562,6 +9593,42 @@ mod tests {
|
|||||||
assert!(!remote_error_closes_connection("engine_error"));
|
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]
|
#[test]
|
||||||
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
|
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
|
||||||
let (db, project, temp) = setup();
|
let (db, project, temp) = setup();
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ use std::path::PathBuf;
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
use objc2::rc::Retained;
|
use objc2::rc::Retained;
|
||||||
use objc2::runtime::ProtocolObject;
|
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
|
use objc2_foundation::{NSAppleEventDescriptor, NSAppleEventManager, NSObject, NSObjectProtocol};
|
||||||
use objc2_app_kit::{NSApplication, NSApplicationDelegate};
|
|
||||||
use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
|
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
|
|
||||||
@@ -16,81 +14,83 @@ pub enum LifecycleEvent {
|
|||||||
UrlOpen(String),
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct DelegateIvars {
|
pub struct HandlerIvars {
|
||||||
tx: mpsc::Sender<LifecycleEvent>,
|
tx: mpsc::Sender<LifecycleEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
define_class!(
|
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))]
|
#[unsafe(super(NSObject))]
|
||||||
#[thread_kind = MainThreadOnly]
|
#[thread_kind = MainThreadOnly]
|
||||||
#[name = "BdsAppDelegate"]
|
#[name = "BdsAppleEventHandler"]
|
||||||
#[ivars = DelegateIvars]
|
#[ivars = HandlerIvars]
|
||||||
pub struct BdsAppDelegate;
|
pub struct BdsAppleEventHandler;
|
||||||
|
|
||||||
// SAFETY: NSObjectProtocol declares no additional safety requirements.
|
// 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
|
impl BdsAppleEventHandler {
|
||||||
// class is main-thread-only, as required by NSApplicationDelegate.
|
#[unsafe(method(handleGetURLEvent:withReplyEvent:))]
|
||||||
unsafe impl NSApplicationDelegate for BdsAppDelegate {
|
fn handle_get_url_event(
|
||||||
#[unsafe(method(applicationDidFinishLaunching:))]
|
&self,
|
||||||
fn did_finish_launching(&self, _notification: &NSNotification) {
|
event: &NSAppleEventDescriptor,
|
||||||
// App is already running via Iced; nothing to do here.
|
_reply: &NSAppleEventDescriptor,
|
||||||
}
|
) {
|
||||||
|
if let Some(url) = event
|
||||||
#[unsafe(method(application:openFile:))]
|
.paramDescriptorForKeyword(four_char_code(*b"----"))
|
||||||
fn application_open_file(&self, _sender: &NSApplication, filename: &NSString) -> bool {
|
.and_then(|descriptor| descriptor.stringValue())
|
||||||
let path = PathBuf::from(filename.to_string());
|
{
|
||||||
let _ = self.ivars().tx.send(LifecycleEvent::FileOpen(path));
|
let _ = self
|
||||||
true
|
.ivars()
|
||||||
}
|
.tx
|
||||||
|
.send(LifecycleEvent::UrlOpen(url.to_string()));
|
||||||
#[unsafe(method(application:openURLs:))]
|
|
||||||
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
|
|
||||||
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 BdsAppDelegate {
|
impl BdsAppleEventHandler {
|
||||||
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
|
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
|
||||||
let this = Self::alloc(mtm);
|
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
|
// SAFETY: `this` has initialized ivars and NSObject's `init` accepts ownership of the
|
||||||
// allocated receiver, returning the retained initialized object.
|
// allocated receiver, returning the retained initialized object.
|
||||||
unsafe { msg_send![super(this), init] }
|
unsafe { msg_send![super(this), init] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the macOS lifecycle channel and wire the native delegate.
|
const fn four_char_code(bytes: [u8; 4]) -> u32 {
|
||||||
///
|
u32::from_be_bytes(bytes)
|
||||||
/// Returns `(Retained<BdsAppDelegate>, Receiver)`. The delegate must be kept alive
|
}
|
||||||
/// for the lifetime of the application (drop it and the callbacks stop).
|
|
||||||
pub fn lifecycle_channel() -> (mpsc::Sender<LifecycleEvent>, mpsc::Receiver<LifecycleEvent>) {
|
pub fn lifecycle_channel() -> (mpsc::Sender<LifecycleEvent>, mpsc::Receiver<LifecycleEvent>) {
|
||||||
mpsc::channel()
|
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.
|
/// Must be called after Iced has created its event loop. The returned handler must remain alive.
|
||||||
/// Returns the retained delegate (caller must keep it alive).
|
pub fn install_lifecycle_handler() -> Option<(
|
||||||
pub fn install_delegate(tx: mpsc::Sender<LifecycleEvent>) -> Option<Retained<BdsAppDelegate>> {
|
Retained<BdsAppleEventHandler>,
|
||||||
|
mpsc::Receiver<LifecycleEvent>,
|
||||||
|
)> {
|
||||||
let mtm = MainThreadMarker::new()?;
|
let mtm = MainThreadMarker::new()?;
|
||||||
let delegate = BdsAppDelegate::new(mtm, tx);
|
let (tx, receiver) = lifecycle_channel();
|
||||||
let app = NSApplication::sharedApplication(mtm);
|
let handler = BdsAppleEventHandler::new(mtm, tx);
|
||||||
let object = ProtocolObject::from_ref(&*delegate);
|
let manager = NSAppleEventManager::sharedAppleEventManager();
|
||||||
app.setDelegate(Some(object));
|
// SAFETY: The selector is implemented above with the two Apple-event descriptor arguments
|
||||||
Some(delegate)
|
// 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.
|
/// 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<LifecycleEvent>) -> Option<Messa
|
|||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn drain_lifecycle(receiver: &mpsc::Receiver<LifecycleEvent>) -> Vec<Message> {
|
||||||
|
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")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user