import SwiftUI import ServiceManagement struct SettingsView: View { @EnvironmentObject private var automation: BackgroundSyncManager @AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName() @AppStorage("defaultSSHKeyPath") private var defaultSSHKeyPath = "" @AppStorage("defaultSSHPort") private var defaultSSHPort = 22 @AppStorage("defaultRemoteHost") private var defaultRemoteHost = "" @AppStorage("backgroundSchedulesEnabled") private var backgroundSchedulesEnabled = true @AppStorage("fileWatchingEnabled") private var fileWatchingEnabled = true @AppStorage("defaultWatchDebounceSeconds") private var defaultWatchDebounceSeconds = 3 @AppStorage("historyRetentionDays") private var historyRetentionDays = 90 @State private var launchAtLogin = false @State private var launchError: String? var body: some View { TabView { Form { Section("Default SSH Connection") { TextField("Username", text: $defaultSSHUsername, prompt: Text(NSUserName())) TextField("Default host", text: $defaultRemoteHost, prompt: Text("server.local")) Stepper("SSH port: \(defaultSSHPort)", value: $defaultSSHPort, in: 1...65535) HStack { TextField("Private SSH key", text: $defaultSSHKeyPath, prompt: Text("~/.ssh/id_ed25519")) Button("Choose…") { if let selected = FilePicker.chooseFile() { defaultSSHKeyPath = selected } } } Label("Authentication uses an SSH private key or your macOS SSH agent. Passwords are intentionally not stored or passed on the command line.", systemImage: "lock.shield") .font(.caption) .foregroundStyle(.secondary) } Section("New Jobs") { Text("New SSH jobs use these values as defaults and can override them independently.") .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .tabItem { Label("Connections", systemImage: "network") } Form { Section("Background Scheduling") { Toggle("Run scheduled jobs", isOn: $backgroundSchedulesEnabled) Toggle("Launch Rsync Manager at login", isOn: Binding( get: { launchAtLogin }, set: { setLaunchAtLogin($0) } )) HStack { Label("Configured jobs", systemImage: "calendar.badge.clock") Spacer() Text("\(automation.scheduledJobCount)") .foregroundStyle(.secondary) } Text("Scheduled jobs run without opening a modal and are written to History. Rsync Manager continues processing them while its window is hidden, minimised or closed, provided the application itself remains running.") .font(.caption) .foregroundStyle(.secondary) } Section("History & Privacy") { Stepper("Keep run history for \(historyRetentionDays) days", value: $historyRetentionDays, in: 7...3650, step: 7) Text("Run output is sanitised, ANSI control sequences are removed, and individual stdout/stderr logs are capped at approximately 2 MB before storage.") .font(.caption) .foregroundStyle(.secondary) } Section("File Watching") { Toggle("Watch enabled jobs for changes", isOn: $fileWatchingEnabled) Stepper("Default debounce: \(defaultWatchDebounceSeconds) seconds", value: $defaultWatchDebounceSeconds, in: 1...60) HStack { Label("Watched jobs", systemImage: "eye.fill") Spacer() Text("\(automation.watchedJobCount)") .foregroundStyle(.secondary) } Text("Rsync Manager uses macOS FSEvents, so watched trees do not need to be repeatedly scanned. A short debounce combines rapid saves into a single sync.") .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .tabItem { Label("Automation", systemImage: "calendar.badge.clock") } Form { Section("Rsync") { LabeledContent("Detected executable", value: RsyncLocator.locate()) LabeledContent("Executable validation", value: RsyncLocator.isTrustedExecutableCandidate(RsyncLocator.locate()) ? "Passed" : "Failed") Text("Only executable regular-file rsync candidates are accepted. Symlinks are resolved before validation.") .font(.caption) .foregroundStyle(.secondary) } Section("Security") { LabeledContent("SSH mode", value: "Batch / key or agent") LabeledContent("Host-key verification", value: "System default") Text("SSH connections use BatchMode, connection timeouts and keepalive limits. Rsync Manager never disables SSH host-key verification.") .font(.caption) .foregroundStyle(.secondary) } Section("Application") { LabeledContent("Bundle identifier", value: Bundle.main.bundleIdentifier ?? "net.absdev.RsyncManager") LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") LabeledContent("Background service", value: automation.activity.title) } } .formStyle(.grouped) .tabItem { Label("General", systemImage: "gearshape") } } .frame(width: 650, height: 455) .onAppear { KeychainStore.delete("defaultSSHPassword") launchAtLogin = SMAppService.mainApp.status == .enabled } .onChange(of: backgroundSchedulesEnabled) { _, _ in automation.refreshConfiguration() } .onChange(of: fileWatchingEnabled) { _, _ in automation.refreshConfiguration() } .alert("Launch at Login", isPresented: Binding( get: { launchError != nil }, set: { if !$0 { launchError = nil } } )) { Button("OK", role: .cancel) { launchError = nil } } message: { Text(launchError ?? "Unable to change the login item setting.") } } private func setLaunchAtLogin(_ enabled: Bool) { do { if enabled { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } launchAtLogin = SMAppService.mainApp.status == .enabled } catch { launchAtLogin = SMAppService.mainApp.status == .enabled launchError = error.localizedDescription } } }