141 lines
6.7 KiB
Swift
141 lines
6.7 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
|
|
@State private var defaultPassword = ""
|
|
@State private var passwordSaved = false
|
|
@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 }
|
|
}
|
|
}
|
|
|
|
SecureField("Default password", text: $defaultPassword)
|
|
HStack {
|
|
Text("Stored securely in the macOS Keychain, never in SwiftData or UserDefaults.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button(passwordSaved ? "Saved" : "Save Password") {
|
|
KeychainStore.set(defaultPassword, for: "defaultSSHPassword")
|
|
passwordSaved = true
|
|
}
|
|
.disabled(defaultPassword.isEmpty)
|
|
}
|
|
}
|
|
|
|
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("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())
|
|
Text("Rsync Manager prefers Homebrew rsync, then /usr/local/bin/rsync, and finally the version supplied with macOS.")
|
|
.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 {
|
|
defaultPassword = KeychainStore.string(for: "defaultSSHPassword")
|
|
passwordSaved = !defaultPassword.isEmpty
|
|
launchAtLogin = SMAppService.mainApp.status == .enabled
|
|
}
|
|
.onChange(of: defaultPassword) { _, _ in passwordSaved = false }
|
|
.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
|
|
}
|
|
}
|
|
}
|