From 48bc41d3401ab71dc3bd4a03841d9f68abe5943a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:10:15 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(macos):=20rFirma=20sabe=20qui=C3=A9n?= =?UTF-8?q?=20abre=20afirma://=20en=20macOS=20y=20deja=20elegirse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2017: rFirma sabe quién abre afirma:// en macOS y deja elegirse - LaunchServices tras la costura Workspace; NSWorkspace la implementa y se prueba a mano - Leer nunca escribe; retirar rFirma devuelve el esquema a otra aplicación que lo declare - Sale PendingMacosLaunchServices y su fila de la tabla del ADR-0040 --- ...n-apple-silicon-como-tercera-plataforma.md | 1 - rfirma-app/src-tauri/Cargo.toml | 4 +- .../src/desktop/adapters/registry.rs | 43 +---- .../adapters/registry/launch_services.rs | 145 +++++++++++++++ .../registry/launch_services/ns_workspace.rs | 73 ++++++++ .../registry/launch_services/tests.rs | 166 ++++++++++++++++++ 6 files changed, 393 insertions(+), 39 deletions(-) create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs diff --git a/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md b/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md index 57d353fb..ad53ab70 100644 --- a/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md +++ b/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md @@ -16,7 +16,6 @@ como fallaría Linux sin el servicio y dice en su nombre qué falta. | `mlock` del secreto | `mlock`; `MADV_DONTDUMP` es solo de Linux. | | Cerrojos (Firefox, carpeta de paso) | Los de Unix, sin cambios. | | Llavero `oo7` (ADR-0034) | `PendingMacosKeychain`: responde que no hay llavero. | -| Manejadores de `afirma://` por GIO | `PendingMacosLaunchServices`: la lista no está disponible y elegir falla. | | CA local en los almacenes NSS (ADR-0005) | `PendingMacosKeychainTrust`, sin perfiles: la CA consta como no instalada. | | Diálogo GTK de fallo de arranque | El texto sale por la salida de error. | diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index c4a65cc1..9e3699fd 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -228,8 +228,8 @@ openssl = { version = "0.10", features = ["vendored"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } block2 = "0.6" objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSSecureTextField", "NSTextField", "NSView", "NSWindow"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSSecureTextField", "NSTextField", "NSView", "NSWindow", "NSWorkspace"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSError", "NSFileManager", "NSGeometry", "NSString", "NSURL"] } [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry.rs index 9f8eafa4..6da4282e 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/registry.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry.rs @@ -1,7 +1,9 @@ -//! `DesktopRegistry`, el registro de manejadores detrás del puerto `HandlerRegistry`: canal, GIO y `mimeapps.list` en Linux, el registro en Windows y Launch Services, pendiente, en macOS. +//! `DesktopRegistry`, el registro de manejadores detrás del puerto `HandlerRegistry`: canal, GIO y `mimeapps.list` en Linux, el registro en Windows y LaunchServices en macOS. use std::path::PathBuf; +#[cfg(target_os = "macos")] +pub mod launch_services; #[cfg(windows)] pub mod windows_classes; @@ -12,7 +14,7 @@ use crate::desktop::adapters::choice::{ choose_handler_for_scheme, current_default_for_scheme, remove_handler_for_scheme, }; use crate::desktop::domain::error::DesktopError; -#[cfg(not(windows))] +#[cfg(target_os = "linux")] use crate::desktop::domain::error::Situation; use crate::desktop::domain::handlers::UrlHandler; use crate::desktop::ports::HandlerRegistry; @@ -88,45 +90,14 @@ pub fn this_desktop_to_write() -> Result, DesktopError> Ok(this_desktop()) } -/// Quién abre `afirma://` en este escritorio, para leerlo: Launch Services, aún pendiente. +/// Quién abre `afirma://` en este escritorio, para leerlo: LaunchServices. #[cfg(target_os = "macos")] pub fn this_desktop() -> Box { - Box::new(PendingMacosLaunchServices) + Box::new(launch_services::of_this_app()) } -/// Quién abre `afirma://` en este escritorio, para escribirlo: Launch Services, aún pendiente. +/// Quién abre `afirma://` en este escritorio, para escribirlo: LaunchServices. #[cfg(target_os = "macos")] pub fn this_desktop_to_write() -> Result, DesktopError> { Ok(this_desktop()) } - -/// El registro de manejadores sobre Launch Services de macOS, que aún no existe: ni se consulta ni se escribe. -#[cfg(target_os = "macos")] -pub struct PendingMacosLaunchServices; - -#[cfg(target_os = "macos")] -impl HandlerRegistry for PendingMacosLaunchServices { - fn registered_for(&self, _scheme: &str) -> Option> { - None - } - - fn current_default_for(&self, _scheme: &str) -> Option { - None - } - - fn choose_for(&self, _scheme: &str, _handler: &str) -> Result<(), DesktopError> { - Err(pending_macos_launch_services()) - } - - fn remove_for(&self, _scheme: &str) -> Result<(), DesktopError> { - Err(pending_macos_launch_services()) - } -} - -#[cfg(target_os = "macos")] -fn pending_macos_launch_services() -> DesktopError { - DesktopError::new( - Situation::TheListIsNotWritable, - "el registro de manejadores de macOS aún no está disponible", - ) -} diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs new file mode 100644 index 00000000..77777ffd --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs @@ -0,0 +1,145 @@ +//! Quién abre un esquema de URL en macOS, según LaunchServices: lo lee siempre y solo lo escribe cuando la persona elige (ADR-0040). + +pub mod ns_workspace; + +use std::path::{Path, PathBuf}; + +use crate::desktop::domain::error::{DesktopError, Situation}; +use crate::desktop::domain::handlers::{UrlHandler, OUR_DESKTOP_FILE}; +use crate::desktop::ports::HandlerRegistry; + +/// Una aplicación que declara el esquema. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledApp { + pub path: PathBuf, + pub name: String, +} + +/// Lo que LaunchServices sabe de un esquema y cómo se le cambia la aplicación. +pub trait Workspace { + /// Las aplicaciones que declaran el esquema. + fn applications_for(&self, scheme: &str) -> Vec; + + /// La aplicación que lo abre hoy. + fn default_for(&self, scheme: &str) -> Option; + + /// Deja esa aplicación como la que lo abre. + fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String>; +} + +/// El registro de manejadores sobre LaunchServices, con el `.app` que es rFirma. +pub struct LaunchServices { + workspace: W, + ours: PathBuf, +} + +impl LaunchServices { + /// El registro sobre `workspace`, con `ours` como rFirma. + pub fn new(workspace: W, ours: PathBuf) -> Self { + Self { workspace, ours } + } + + fn is_ours(&self, app: &Path) -> bool { + same_app(app, &self.ours) + } + + fn id_of(&self, app: &Path) -> String { + if self.is_ours(app) { + return OUR_DESKTOP_FILE.to_owned(); + } + app.display().to_string() + } + + fn app_of(&self, scheme: &str, handler: &str) -> Option { + if handler == OUR_DESKTOP_FILE { + return Some(self.ours.clone()); + } + self.workspace + .applications_for(scheme) + .into_iter() + .map(|app| app.path) + .find(|path| self.id_of(path) == handler) + } + + fn set_default(&self, scheme: &str, app: &Path) -> Result<(), DesktopError> { + self.workspace + .set_default_for(scheme, app) + .map_err(|reason| DesktopError::new(Situation::TheListIsNotWritable, reason)) + } +} + +impl HandlerRegistry for LaunchServices { + fn registered_for(&self, scheme: &str) -> Option> { + let mut handlers: Vec = Vec::new(); + for app in self.workspace.applications_for(scheme) { + let id = self.id_of(&app.path); + if !handlers.iter().any(|already| already.id == id) { + handlers.push(UrlHandler { id, name: app.name }); + } + } + Some(handlers) + } + + fn current_default_for(&self, scheme: &str) -> Option { + self.workspace + .default_for(scheme) + .map(|app| self.id_of(&app)) + } + + fn choose_for(&self, scheme: &str, handler: &str) -> Result<(), DesktopError> { + let app = self.app_of(scheme, handler).ok_or_else(|| { + DesktopError::new( + Situation::TheListIsNotWritable, + format!("«{handler}» no declara {scheme}://"), + ) + })?; + self.set_default(scheme, &app) + } + + /// LaunchServices no deja un esquema sin aplicación: si lo abre rFirma, pasa a la siguiente que lo declare. + fn remove_for(&self, scheme: &str) -> Result<(), DesktopError> { + if !self + .workspace + .default_for(scheme) + .is_some_and(|app| self.is_ours(&app)) + { + return Ok(()); + } + match self + .workspace + .applications_for(scheme) + .into_iter() + .find(|app| !self.is_ours(&app.path)) + { + Some(other) => self.set_default(scheme, &other.path), + None => Ok(()), + } + } +} + +fn same_app(app: &Path, ours: &Path) -> bool { + let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + !ours.as_os_str().is_empty() && canonical(app) == canonical(ours) +} + +/// El LaunchServices de la sesión, con el `.app` que está corriendo como rFirma. +pub fn of_this_app() -> LaunchServices { + let executable = std::env::current_exe().unwrap_or_default(); + LaunchServices::new(ns_workspace::SystemWorkspace, bundle_of(&executable)) +} + +/// El `.app` que contiene el ejecutable o, fuera de uno, el ejecutable. +pub fn bundle_of(executable: &Path) -> PathBuf { + executable + .ancestors() + .find(|folder| { + folder + .extension() + .is_some_and(|extension| extension == "app") + }) + .unwrap_or(executable) + .to_path_buf() +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs new file mode 100644 index 00000000..8062bb25 --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs @@ -0,0 +1,73 @@ +//! `SystemWorkspace`, el `NSWorkspace` de AppKit detrás de `Workspace`: toca LaunchServices de verdad y se prueba a mano. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use block2::RcBlock; +use objc2::rc::{autoreleasepool, Retained}; +use objc2_app_kit::NSWorkspace; +use objc2_foundation::{NSError, NSFileManager, NSString, NSURL}; + +use super::{InstalledApp, Workspace}; + +const THE_PERSON_CONFIRMS_WITHIN: Duration = Duration::from_secs(120); + +/// El LaunchServices de la sesión de la persona. +pub struct SystemWorkspace; + +impl Workspace for SystemWorkspace { + fn applications_for(&self, scheme: &str) -> Vec { + autoreleasepool(|_| { + let Some(url) = url_of(scheme) else { + return Vec::new(); + }; + let files = NSFileManager::defaultManager(); + NSWorkspace::sharedWorkspace() + .URLsForApplicationsToOpenURL(&url) + .iter() + .filter_map(|app| { + let path = app.path()?; + Some(InstalledApp { + name: files.displayNameAtPath(&path).to_string(), + path: PathBuf::from(path.to_string()), + }) + }) + .collect() + }) + } + + fn default_for(&self, scheme: &str) -> Option { + autoreleasepool(|_| { + let url = url_of(scheme)?; + let app = NSWorkspace::sharedWorkspace().URLForApplicationToOpenURL(&url)?; + Some(PathBuf::from(app.path()?.to_string())) + }) + } + + fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String> { + let (sender, answer) = mpsc::channel(); + let done = RcBlock::new(move |error: *mut NSError| { + let outcome = unsafe { error.as_ref() }.map_or(Ok(()), |error| { + Err(error.localizedDescription().to_string()) + }); + let _ = sender.send(outcome); + }); + autoreleasepool(|_| { + let app = NSURL::fileURLWithPath(&NSString::from_str(&app.to_string_lossy())); + NSWorkspace::sharedWorkspace() + .setDefaultApplicationAtURL_toOpenURLsWithScheme_completionHandler( + &app, + &NSString::from_str(scheme), + Some(&done), + ); + }); + answer + .recv_timeout(THE_PERSON_CONFIRMS_WITHIN) + .map_err(|_| format!("macOS no ha confirmado quién abre {scheme}://"))? + } +} + +fn url_of(scheme: &str) -> Option> { + NSURL::URLWithString(&NSString::from_str(&format!("{scheme}://"))) +} diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs new file mode 100644 index 00000000..0ea4f050 --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs @@ -0,0 +1,166 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use super::*; +use crate::desktop::application::handlers::{chosen, who_handles, withdrawn, SCHEME}; +use crate::desktop::domain::handlers::{UrlHandler, OUR_DESKTOP_FILE}; +use crate::desktop::domain::withdrawal::Withdrawal; + +const RFIRMA: &str = "/Applications/rFirma.app"; +const AUTOFIRMA: &str = "/Applications/AutoFirma.app"; + +struct FakeWorkspace { + apps: Vec, + default: RefCell>, + written: RefCell>, +} + +impl FakeWorkspace { + fn with_both_installed_and_chosen(chosen: &str) -> Self { + Self { + apps: vec![an_app(RFIRMA, "rFirma"), an_app(AUTOFIRMA, "AutoFirma")], + default: RefCell::new(Some(PathBuf::from(chosen))), + written: RefCell::new(Vec::new()), + } + } + + fn written(&self) -> Vec { + self.written.borrow().clone() + } +} + +impl Workspace for &FakeWorkspace { + fn applications_for(&self, scheme: &str) -> Vec { + assert_eq!(scheme, SCHEME); + self.apps.clone() + } + + fn default_for(&self, scheme: &str) -> Option { + assert_eq!(scheme, SCHEME); + self.default.borrow().clone() + } + + fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String> { + assert_eq!(scheme, SCHEME); + self.written.borrow_mut().push(app.to_path_buf()); + *self.default.borrow_mut() = Some(app.to_path_buf()); + Ok(()) + } +} + +fn an_app(path: &str, name: &str) -> InstalledApp { + InstalledApp { + path: PathBuf::from(path), + name: name.to_owned(), + } +} + +fn launch_services(workspace: &FakeWorkspace) -> LaunchServices<&FakeWorkspace> { + LaunchServices::new(workspace, PathBuf::from(RFIRMA)) +} + +#[test] +fn rfirma_is_the_bundle_that_holds_the_running_executable() { + assert_eq!( + bundle_of(Path::new("/Applications/rFirma.app/Contents/MacOS/rfirma")), + PathBuf::from(RFIRMA) + ); +} + +#[test] +fn outside_a_bundle_rfirma_is_the_executable_itself() { + let executable = Path::new("/Users/someone/rfirma/target/debug/rfirma"); + + assert_eq!(bundle_of(executable), executable); +} + +#[test] +fn with_autofirma_chosen_both_are_listed_and_autofirma_is_current() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + let view = who_handles(&launch_services(&workspace)); + + assert!(view.available); + assert_eq!( + view.handlers, + vec![ + UrlHandler { + id: OUR_DESKTOP_FILE.to_owned(), + name: "rFirma".to_owned(), + }, + UrlHandler { + id: AUTOFIRMA.to_owned(), + name: "AutoFirma".to_owned(), + }, + ] + ); + assert_eq!(view.current.as_deref(), Some(AUTOFIRMA)); +} + +#[test] +fn reading_who_handles_the_scheme_never_takes_it() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + who_handles(&launch_services(&workspace)); + + assert!(workspace.written().is_empty()); +} + +#[test] +fn choosing_rfirma_makes_it_the_one_that_opens_the_scheme() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + chosen(&launch_services(&workspace), OUR_DESKTOP_FILE).expect("debería elegirse"); + + assert_eq!(workspace.written(), vec![PathBuf::from(RFIRMA)]); + assert_eq!( + who_handles(&launch_services(&workspace)).current.as_deref(), + Some(OUR_DESKTOP_FILE) + ); +} + +#[test] +fn choosing_autofirma_again_gives_the_scheme_back_to_it() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(RFIRMA); + + chosen(&launch_services(&workspace), AUTOFIRMA).expect("debería elegirse"); + + assert_eq!( + who_handles(&launch_services(&workspace)).current.as_deref(), + Some(AUTOFIRMA) + ); +} + +#[test] +fn choosing_an_application_that_does_not_declare_the_scheme_fails_without_writing() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + let failure = chosen(&launch_services(&workspace), "/Applications/Otra.app") + .expect_err("no declara el esquema"); + + assert!(!failure.detail().is_empty()); + assert!(workspace.written().is_empty()); +} + +#[test] +fn withdrawing_while_autofirma_is_chosen_leaves_it_untouched() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + let outcome = withdrawn(&launch_services(&workspace)); + + assert_eq!(outcome, Withdrawal::WasNotThere); + assert!(workspace.written().is_empty()); +} + +#[test] +fn withdrawing_rfirma_hands_the_scheme_back_to_autofirma() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(RFIRMA); + + let outcome = withdrawn(&launch_services(&workspace)); + + assert_eq!(outcome, Withdrawal::Withdrawn); + assert_eq!( + who_handles(&launch_services(&workspace)).current.as_deref(), + Some(AUTOFIRMA) + ); +} From 1c9023d904a5f395c9981e196413c4975cbbe379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:16:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(macos):=20el=20bloque=20inseguro=20del?= =?UTF-8?q?=20aviso=20de=20LaunchServices=20dice=20por=20qu=C3=A9=20es=20s?= =?UTF-8?q?eguro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/adapters/registry/launch_services/ns_workspace.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs index 8062bb25..e39d2c78 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs @@ -48,6 +48,7 @@ impl Workspace for SystemWorkspace { fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String> { let (sender, answer) = mpsc::channel(); let done = RcBlock::new(move |error: *mut NSError| { + // SAFETY: AppKit pasa un `NSError` vivo mientras dura la llamada, o nulo si fue bien. let outcome = unsafe { error.as_ref() }.map_or(Ok(()), |error| { Err(error.localizedDescription().to_string()) });