diff --git a/docs/spec/settings.md b/docs/spec/settings.md index f000711a..2e15e2d4 100644 --- a/docs/spec/settings.md +++ b/docs/spec/settings.md @@ -6,7 +6,8 @@ behavior for Rsnap. Status: normative Read this when: You are implementing, reviewing, or validating Settings, status-menu commands, -shortcut presentation, permission recovery, Dock activation policy, or Settings window behavior. +shortcut presentation, launch-at-login configuration, permission recovery, Dock activation policy, +or Settings window behavior. Not this document: Detailed visual styling, implementation-specific AppKit/SwiftUI structure, or capture-session behavior once capture has started. Use `docs/spec/capture-session.md` for @@ -16,6 +17,7 @@ Defines: - Settings window and app-shell behavior - status-menu command placement - shortcut configuration and presentation rules +- launch-at-login configuration - permission recovery placement - Settings interaction and default-size usability invariants @@ -29,6 +31,10 @@ Defines: window is visible. - Capture sessions must not require visible Dock activation artifacts to begin, complete, cancel, copy, save, or restore focus. +- Settings must expose an Open at Login control that registers or unregisters Rsnap with macOS + Login Items. +- The Open at Login control must reflect system Login Items state, including pending approval or an + unavailable packaged-app context, instead of only reflecting a stored user default. ## Status Menu @@ -62,6 +68,7 @@ Defines: ## Default Configuration - Capture shortcut: `Option-X`. +- Open at Login: off, until the user enables the macOS Login Items registration. - Output directory: `~/Desktop`. - Output filename prefix: `Rsnap`. - Output naming: timestamp. @@ -81,6 +88,8 @@ Defines: - Screen Recording permission is required for the current native macOS capture host. - Settings must present Screen Recording as the only permission needed by the current native macOS capture host. +- The Open at Login control must live at the bottom of the Permissions section so OS-owned app + access controls remain first. - When Screen Recording is missing at launch or at capture start, Rsnap must open the macOS Screen Recording privacy page and present a small Rsnap-owned floating drag guide near System Settings. - Accessibility and Input Monitoring must not be displayed in Settings while the current native host diff --git a/native/macos-host/Package.swift b/native/macos-host/Package.swift index 710b6867..151bd113 100644 --- a/native/macos-host/Package.swift +++ b/native/macos-host/Package.swift @@ -60,6 +60,7 @@ let package = Package( .linkedFramework("CoreMedia"), .linkedFramework("CoreVideo"), .linkedFramework("ScreenCaptureKit"), + .linkedFramework("ServiceManagement"), .linkedFramework("Vision"), ] ), diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LaunchAtLoginController.swift b/native/macos-host/Sources/RsnapNativeHostKit/LaunchAtLoginController.swift new file mode 100644 index 00000000..c6f1dfaa --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/LaunchAtLoginController.swift @@ -0,0 +1,128 @@ +import Foundation +import ServiceManagement + +package struct LaunchAtLoginState: Equatable { + package let isOn: Bool + package let isControlEnabled: Bool + package let subtitle: String + package let helpText: String + + @MainActor + static func current(errorMessage: String? = nil) -> Self { + LaunchAtLoginController.currentState(errorMessage: errorMessage) + } +} + +package enum LaunchAtLoginStatusSnapshot: Equatable { + case notRegistered + case enabled + case requiresApproval + case notFound + case unknown +} + +package enum LaunchAtLoginController { + @MainActor + package static var currentStatus: LaunchAtLoginStatusSnapshot { + snapshot(for: SMAppService.mainApp.status) + } + + @MainActor + static func setEnabled(_ isEnabled: Bool) throws { + let service = SMAppService.mainApp + let status = snapshot(for: service.status) + + if isEnabled { + switch status { + case .enabled, .requiresApproval: + return + case .notRegistered, .notFound, .unknown: + try service.register() + } + return + } + + switch status { + case .enabled, .requiresApproval, .unknown: + try service.unregister() + case .notRegistered, .notFound: + return + } + } + + @MainActor + package static func currentState(errorMessage: String? = nil) -> LaunchAtLoginState { + state(for: currentStatus, errorMessage: errorMessage) + } + + package static func state( + for status: LaunchAtLoginStatusSnapshot, + errorMessage: String? = nil + ) -> LaunchAtLoginState { + let base = baseState(for: status) + guard let errorMessage, !errorMessage.isEmpty else { + return base + } + return LaunchAtLoginState( + isOn: base.isOn, + isControlEnabled: base.isControlEnabled, + subtitle: "Update failed.", + helpText: errorMessage + ) + } + + private static func baseState(for status: LaunchAtLoginStatusSnapshot) -> LaunchAtLoginState { + switch status { + case .enabled: + return LaunchAtLoginState( + isOn: true, + isControlEnabled: true, + subtitle: "Starts at sign-in.", + helpText: "Rsnap is registered in macOS Login Items." + ) + case .requiresApproval: + return LaunchAtLoginState( + isOn: true, + isControlEnabled: true, + subtitle: "Needs approval.", + helpText: "Approve Rsnap in System Settings > General > Login Items." + ) + case .notRegistered: + return LaunchAtLoginState( + isOn: false, + isControlEnabled: true, + subtitle: "Manual startup.", + helpText: "Register Rsnap with macOS Login Items." + ) + case .notFound: + return LaunchAtLoginState( + isOn: false, + isControlEnabled: true, + subtitle: "Try enabling.", + helpText: "Register Rsnap with macOS Login Items." + ) + case .unknown: + return LaunchAtLoginState( + isOn: false, + isControlEnabled: true, + subtitle: "Status unknown.", + helpText: "macOS returned an unknown Login Items status." + ) + } + } + + private static func snapshot(for status: SMAppService.Status) -> LaunchAtLoginStatusSnapshot { + switch status { + case .notRegistered: + return .notRegistered + case .enabled: + return .enabled + case .requiresApproval: + return .requiresApproval + case .notFound: + return .notFound + @unknown default: + return .unknown + } + } +} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index 087e817b..f4bdb36b 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -12,6 +12,7 @@ enum NativeHostSettingsWindowMetrics { @MainActor final class NativeHostSettingsViewModel: ObservableObject { @Published private(set) var settings: NativeHostSettings + @Published private(set) var launchAtLoginState = LaunchAtLoginState.current() private let settingsStore: NativeHostSettingsStore @@ -22,6 +23,7 @@ final class NativeHostSettingsViewModel: ObservableObject { func refresh() { settings = settingsStore.settings + launchAtLoginState = LaunchAtLoginState.current() } func update(_ mutate: (inout NativeHostSettings) -> Void) { @@ -33,6 +35,16 @@ final class NativeHostSettingsViewModel: ObservableObject { update { $0 = NativeHostSettings.defaults } } + func setLaunchAtLoginEnabled(_ isEnabled: Bool) { + do { + try LaunchAtLoginController.setEnabled(isEnabled) + launchAtLoginState = LaunchAtLoginState.current() + } catch { + launchAtLoginState = LaunchAtLoginState.current( + errorMessage: error.localizedDescription) + } + } + func chooseOutputDirectory() { let panel = NSOpenPanel() panel.canChooseDirectories = true @@ -320,7 +332,7 @@ private struct SettingsDashboard: View { case .output: OutputSettingsPanel(model: model) case .permissions: - PermissionsSettingsPanel() + PermissionsSettingsPanel(model: model) case .about: AboutSettingsPanel() } @@ -903,6 +915,24 @@ private struct ModernSegmentButton: View { } } +private struct LaunchAtLoginToggle: View { + @ObservedObject var model: NativeHostSettingsViewModel + + var body: some View { + Toggle( + "", + isOn: Binding( + get: { model.launchAtLoginState.isOn }, + set: { value in model.setLaunchAtLoginEnabled(value) } + ) + ) + .labelsHidden() + .toggleStyle(SettingsToggleStyle()) + .disabled(!model.launchAtLoginState.isControlEnabled) + .help(model.launchAtLoginState.helpText) + } +} + private struct AppearanceSettingsPanel: View { @ObservedObject var model: NativeHostSettingsViewModel @@ -1429,23 +1459,35 @@ private struct CaptureHotKeyField: View { } private struct PermissionsSettingsPanel: View { + @ObservedObject var model: NativeHostSettingsViewModel @State private var refreshID = 0 private let primaryKind = PermissionKind.screenRecording var body: some View { - VStack(spacing: 0) { - PermissionGrantCard( - kind: primaryKind, - refreshID: refreshID, - bundleURL: Self.appBundleURL, - appIcon: Self.appIcon, - openSettings: { - NativePermissions.openSystemSettings(for: primaryKind) - }, - refresh: { - refreshID += 1 - } - ) + VStack(spacing: 8) { + VStack(spacing: 0) { + PermissionGrantCard( + kind: primaryKind, + refreshID: refreshID, + bundleURL: Self.appBundleURL, + appIcon: Self.appIcon, + openSettings: { + NativePermissions.openSystemSettings(for: primaryKind) + }, + refresh: { + refreshID += 1 + model.refresh() + } + ) + } + + SettingsHeroControlTile( + symbolName: "power", + title: "Open at Login", + subtitle: model.launchAtLoginState.subtitle + ) { + LaunchAtLoginToggle(model: model) + } } } diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index e7d26e70..c9cb17e8 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -77,6 +77,7 @@ enum RsnapNativeHostKitProbe { CGRect(x: 24, y: 54, width: 96, height: 192), "scroll minimap should fall back to the left when the right side is constrained" ) + assertLaunchAtLoginStateMapping() } private static func assertRectEqual(_ actual: CGRect, _ expected: CGRect, _ message: String) { @@ -100,6 +101,30 @@ enum RsnapNativeHostKitProbe { abs(actual - expected) <= 0.000_1 } + private static func assertLaunchAtLoginStateMapping() { + let enabled = LaunchAtLoginController.state(for: .enabled) + guard enabled.isOn, enabled.isControlEnabled else { + fatalError("enabled login item state should keep the toggle on") + } + + let pending = LaunchAtLoginController.state(for: .requiresApproval) + guard pending.isOn, pending.subtitle.contains("approval") else { + fatalError("pending login item state should explain approval") + } + + let missingBundle = LaunchAtLoginController.state(for: .notFound) + guard !missingBundle.isOn, missingBundle.isControlEnabled else { + fatalError("missing app bundle should keep the login item toggle clickable") + } + + let failed = LaunchAtLoginController.state( + for: .notRegistered, + errorMessage: "registration failed") + guard !failed.isOn, failed.subtitle.contains("failed") else { + fatalError("failed login item update should keep current state and surface failure") + } + } + private static func assertRectOverlayDrawsAtVisualTop( _ rect: CGRect, selection: CGRect, diff --git a/scripts/build_and_run.sh b/scripts/build_and_run.sh index f1297af3..01802710 100755 --- a/scripts/build_and_run.sh +++ b/scripts/build_and_run.sh @@ -96,6 +96,7 @@ relink_native_host_if_missing() { -Xlinker /Library/Developer/CommandLineTools/usr/lib/swift-6.2/macosx \ -target arm64-apple-macosx14.0 \ -framework AppKit \ + -framework ServiceManagement \ -framework Vision \ -L "$RUST_LIB_DIR" \ -lrsnap_host_ffi \