From 0449bfd0dc703c73d9b9d291b45106403a6b2751 Mon Sep 17 00:00:00 2001 From: 404oops Date: Fri, 2 Oct 2026 22:54:40 +0200 Subject: [PATCH] Fix launch-at-login registration and installer state --- crates/immersion/src/app.rs | 1 + crates/immersion/src/platform/mac.rs | 40 +++++++++++++++++++---- crates/immersion/src/platform/mod.rs | 30 +++++++++++++++-- crates/immersion/src/ui/modals.rs | 11 ++++++- crates/musit-core/src/backend.rs | 12 ++++++- crates/musit-core/tests/autostart.rs | 39 ++++++++++++++++++++++ crates/musit-core/tests/e2e_backup.rs | 1 + crates/musit-core/tests/folder_session.rs | 1 + packaging/nsis/Immersion.nsi | 2 +- wiki/Settings-and-Storage.md | 2 +- 10 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 crates/musit-core/tests/autostart.rs diff --git a/crates/immersion/src/app.rs b/crates/immersion/src/app.rs index 39dafa3..d188056 100644 --- a/crates/immersion/src/app.rs +++ b/crates/immersion/src/app.rs @@ -293,6 +293,7 @@ impl RootView { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let platform_hooks = PlatformHooks { launch_at_startup_supported: crate::platform::is_launch_at_startup_supported(), + launch_at_startup_enabled: crate::platform::launch_at_startup_enabled(), set_launch_at_startup: Box::new(crate::platform::set_launch_at_startup), ..PlatformHooks::default() }; diff --git a/crates/immersion/src/platform/mac.rs b/crates/immersion/src/platform/mac.rs index a300470..7fa11cf 100644 --- a/crates/immersion/src/platform/mac.rs +++ b/crates/immersion/src/platform/mac.rs @@ -6,7 +6,7 @@ use std::cell::{Cell, RefCell}; use std::time::{Duration, Instant}; use objc2::rc::Retained; -use objc2::runtime::{AnyObject, Bool, NSObjectProtocol, ProtocolObject}; +use objc2::runtime::{AnyClass, AnyObject, Bool, NSObjectProtocol, ProtocolObject}; use objc2::{AllocAnyThread, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; use objc2_app_kit::{ NSApplication, NSApplicationActivationPolicy, NSApplicationDidBecomeActiveNotification, @@ -407,20 +407,46 @@ pub fn install_status_item(on_open: Box, on_quit: Box) { // ---- Launch at login (SMAppService) --------------------------------------- pub fn is_launch_at_startup_supported() -> bool { - is_bundled() + // SMAppService was introduced in macOS 13, while the bundle still runs + // on macOS 12. Looking up the class avoids messaging an unavailable API. + is_bundled() && AnyClass::get(c"SMAppService").is_some() +} + +/// Registration can succeed while macOS still waits for the user's approval. +pub fn launch_at_startup_needs_approval() -> bool { + if !is_launch_at_startup_supported() { + return false; + } + use objc2_service_management::{SMAppService, SMAppServiceStatus}; + unsafe { SMAppService::mainAppService().status() == SMAppServiceStatus::RequiresApproval } } pub fn set_launch_at_startup(enabled: bool) { - if !is_bundled() { + if !is_launch_at_startup_supported() { return; } unsafe { - use objc2_service_management::SMAppService; + use objc2_service_management::{SMAppService, SMAppServiceStatus}; let service = SMAppService::mainAppService(); - if enabled { - let _ = service.registerAndReturnError(); + let status = service.status(); + let result = if enabled + && matches!( + status, + SMAppServiceStatus::NotRegistered | SMAppServiceStatus::NotFound + ) { + service.registerAndReturnError() + } else if !enabled + && matches!( + status, + SMAppServiceStatus::Enabled | SMAppServiceStatus::RequiresApproval + ) + { + service.unregisterAndReturnError() } else { - let _ = service.unregisterAndReturnError(); + return; + }; + if let Err(error) = result { + eprintln!("Immersion launch at login could not be updated: {error}"); } } } diff --git a/crates/immersion/src/platform/mod.rs b/crates/immersion/src/platform/mod.rs index 07532ab..e2e9071 100644 --- a/crates/immersion/src/platform/mod.rs +++ b/crates/immersion/src/platform/mod.rs @@ -56,9 +56,9 @@ pub use win::file_type_icon_png; #[cfg(target_os = "macos")] pub use mac::{ adopt_main_window, file_type_icon_png, hide_main_window, install_activation_observer, - install_status_item, is_launch_at_startup_supported, launched_at_login, - set_background_agent_mode, set_dock_icon_if_unbundled, set_launch_at_startup, show_main_window, - show_notification, suppress_launch_activation_reveal, + install_status_item, is_launch_at_startup_supported, launch_at_startup_needs_approval, + launched_at_login, set_background_agent_mode, set_dock_icon_if_unbundled, + set_launch_at_startup, show_main_window, show_notification, suppress_launch_activation_reveal, }; /// OS document icon for a file extension as PNG bytes. macOS, Windows and @@ -71,6 +71,11 @@ pub fn file_type_icon_png(_extension: &str, _size_px: usize) -> Option> #[cfg(not(target_os = "macos"))] pub fn set_background_agent_mode(_enabled: bool) {} +#[cfg(not(target_os = "macos"))] +pub fn launch_at_startup_needs_approval() -> bool { + false +} + #[cfg(not(target_os = "macos"))] pub fn adopt_main_window(_title: &str) {} @@ -87,6 +92,25 @@ pub fn show_main_window() -> bool { #[cfg(not(target_os = "macos"))] pub fn install_activation_observer(_on_activate: Box) {} +// Launch at login on Windows: HKCU Run key. +#[cfg(target_os = "windows")] +pub fn launch_at_startup_enabled() -> Option { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; + std::process::Command::new("reg") + .args(["query", RUN_KEY, "/v", "Immersion"]) + .creation_flags(CREATE_NO_WINDOW) + .status() + .ok() + .map(|status| status.success()) +} + +#[cfg(not(target_os = "windows"))] +pub fn launch_at_startup_enabled() -> Option { + None +} + // Launch at login on Windows: HKCU Run key. #[cfg(target_os = "windows")] pub fn set_launch_at_startup(enabled: bool) { diff --git a/crates/immersion/src/ui/modals.rs b/crates/immersion/src/ui/modals.rs index c967bae..36767ba 100644 --- a/crates/immersion/src/ui/modals.rs +++ b/crates/immersion/src/ui/modals.rs @@ -327,7 +327,16 @@ impl RootView { .child(section_label("Launch at login")) .child(meta_label( "Start Immersion automatically when you sign in.", - )), + )) + .when( + launch_at_startup + && crate::platform::launch_at_startup_needs_approval(), + |el| { + el.child(meta_label( + "Allow Immersion in System Settings > General > Login Items & Extensions.", + )) + }, + ), ) .child(themed_switch( "launch-switch", diff --git a/crates/musit-core/src/backend.rs b/crates/musit-core/src/backend.rs index ca82e0d..606fc41 100644 --- a/crates/musit-core/src/backend.rs +++ b/crates/musit-core/src/backend.rs @@ -217,6 +217,9 @@ pub enum BackendEvent { /// Host-provided platform hooks. pub struct PlatformHooks { pub launch_at_startup_supported: bool, + /// Current OS login entry, when the platform exposes one to reconcile + /// with the app setting (the Windows installer can create it first). + pub launch_at_startup_enabled: Option, pub set_launch_at_startup: Box, /// Opens a file or folder with the OS default handler. Returns success. pub open_path: Box bool + Send>, @@ -226,6 +229,7 @@ impl Default for PlatformHooks { fn default() -> Self { Self { launch_at_startup_supported: false, + launch_at_startup_enabled: None, set_launch_at_startup: Box::new(|_| {}), open_path: Box::new(default_open_path), } @@ -1083,10 +1087,16 @@ impl AppBackend { } else { &saved_settings.color_scheme_mode }); - backend.launch_at_startup = saved_settings.launch_at_startup; + backend.launch_at_startup = backend + .platform + .launch_at_startup_enabled + .unwrap_or(saved_settings.launch_at_startup); if backend.platform.launch_at_startup_supported { (backend.platform.set_launch_at_startup)(backend.launch_at_startup); } + if backend.launch_at_startup != saved_settings.launch_at_startup { + backend.persist_app_settings(); + } for saved_folder in backend.project_registry.load_projects_folders() { if folder_settings::has_layout_setting(&saved_folder) { diff --git a/crates/musit-core/tests/autostart.rs b/crates/musit-core/tests/autostart.rs new file mode 100644 index 0000000..e6e9ecf --- /dev/null +++ b/crates/musit-core/tests/autostart.rs @@ -0,0 +1,39 @@ +//! The installer can set the OS login entry before Immersion has app settings. + +use std::sync::{Arc, Mutex}; + +use musit_core::backend::{AppBackend, PlatformHooks}; +use musit_core::project_registry::{self, AppSettings, ProjectRegistry}; + +#[test] +fn os_login_entry_is_reconciled_with_saved_settings() { + let config = tempfile::tempdir().expect("temp config dir"); + project_registry::set_app_config_directory_override(Some( + config.path().to_string_lossy().into_owned(), + )); + let registry = ProjectRegistry; + assert!(registry.save_app_settings(&AppSettings::default())); + + let applied = Arc::new(Mutex::new(Vec::new())); + let platform = |enabled| { + let applied = applied.clone(); + PlatformHooks { + launch_at_startup_supported: true, + launch_at_startup_enabled: Some(enabled), + set_launch_at_startup: Box::new(move |value| { + applied.lock().expect("applied lock").push(value); + }), + ..PlatformHooks::default() + } + }; + + let backend = AppBackend::with_platform(platform(true)); + assert!(backend.launch_at_startup()); + assert!(registry.load_app_settings().launch_at_startup); + drop(backend); + + let backend = AppBackend::with_platform(platform(false)); + assert!(!backend.launch_at_startup()); + assert!(!registry.load_app_settings().launch_at_startup); + assert_eq!(*applied.lock().expect("applied lock"), [true, false]); +} diff --git a/crates/musit-core/tests/e2e_backup.rs b/crates/musit-core/tests/e2e_backup.rs index 641f6b8..52bd2f5 100644 --- a/crates/musit-core/tests/e2e_backup.rs +++ b/crates/musit-core/tests/e2e_backup.rs @@ -119,6 +119,7 @@ fn project_index_by_file(backend: &AppBackend, file_name: &str) -> i32 { fn test_platform() -> PlatformHooks { PlatformHooks { launch_at_startup_supported: false, + launch_at_startup_enabled: None, set_launch_at_startup: Box::new(|_| {}), // Never actually launch DAWs/Finder from the test suite. open_path: Box::new(|_| true), diff --git a/crates/musit-core/tests/folder_session.rs b/crates/musit-core/tests/folder_session.rs index f92542f..294af86 100644 --- a/crates/musit-core/tests/folder_session.rs +++ b/crates/musit-core/tests/folder_session.rs @@ -13,6 +13,7 @@ use std::time::{Duration, Instant}; fn test_platform() -> PlatformHooks { PlatformHooks { launch_at_startup_supported: false, + launch_at_startup_enabled: None, set_launch_at_startup: Box::new(|_| {}), open_path: Box::new(|_| true), } diff --git a/packaging/nsis/Immersion.nsi b/packaging/nsis/Immersion.nsi index d5bb0d8..719a8d4 100644 --- a/packaging/nsis/Immersion.nsi +++ b/packaging/nsis/Immersion.nsi @@ -82,7 +82,7 @@ FunctionEnd Function StartupPageLeave ${NSD_GetState} $StartupCheckbox $StartupEnabled ${If} $StartupEnabled == ${BST_CHECKED} - WriteRegStr HKCU "${REG_RUN}" "${APP_NAME}" "$INSTDIR\Immersion.exe" + WriteRegStr HKCU "${REG_RUN}" "${APP_NAME}" '"$INSTDIR\Immersion.exe" --autostart' ${Else} DeleteRegValue HKCU "${REG_RUN}" "${APP_NAME}" ${EndIf} diff --git a/wiki/Settings-and-Storage.md b/wiki/Settings-and-Storage.md index 284bd93..99b4477 100644 --- a/wiki/Settings-and-Storage.md +++ b/wiki/Settings-and-Storage.md @@ -6,7 +6,7 @@ Open **Settings** from the main window. Changes to a folder layout trigger a res | --- | --- | | **Projects folder** | Shows the active folder and lets you choose or add another. Multiple folders can be watched at once. | | **Folder layout** | Choose **Bundles** or **Files** for the active folder. The choice is stored in that folder's `.immersion/settings.json`. | -| **Launch at login** | Starts Immersion when you sign in where the platform supports it. The packaged macOS app is required for this integration. On Linux it writes an XDG autostart entry; it is not available in the Flatpak. | +| **Launch at login** | Starts Immersion when you sign in where the platform supports it. On macOS 13 or later, the packaged app uses Login Items and may need approval in System Settings. On Linux it writes an XDG autostart entry; it is not available in the Flatpak. | | **Default sort mode** | Start the project list sorted by **Name** or **Last Opened**. | | **Activity log level** | **Info** shows normal events; **Debug** adds scan, watcher, and storage details. | | **Snapshot retention** | Number of recent versions with fast uncompressed copies, from 1 to 50; default 5. Older versions remain in compressed storage. |