Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/spec/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions native/macos-host/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ let package = Package(
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("ScreenCaptureKit"),
.linkedFramework("ServiceManagement"),
.linkedFramework("Vision"),
]
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -22,6 +23,7 @@ final class NativeHostSettingsViewModel: ObservableObject {

func refresh() {
settings = settingsStore.settings
launchAtLoginState = LaunchAtLoginState.current()
}

func update(_ mutate: (inout NativeHostSettings) -> Void) {
Expand All @@ -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
Expand Down Expand Up @@ -320,7 +332,7 @@ private struct SettingsDashboard: View {
case .output:
OutputSettingsPanel(model: model)
case .permissions:
PermissionsSettingsPanel()
PermissionsSettingsPanel(model: model)
case .about:
AboutSettingsPanel()
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}
}

Expand Down
25 changes: 25 additions & 0 deletions native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions scripts/build_and_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Loading