Files

145 lines
7.2 KiB
Swift

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
}
}
}