Files
2026-09-08 20:06:58 +01:00

305 lines
15 KiB
Swift

import SwiftUI
import ServiceManagement
import SwiftData
import AppKit
import UniformTypeIdentifiers
import CryptoKit
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@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?
@State private var diagnosticsMessage: String?
@State private var updateStatus: String?
@State private var isCheckingUpdates = false
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 Studio 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 Studio 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 Studio 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 Studio never disables SSH host-key verification.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Application") {
LabeledContent("Bundle identifier", value: Bundle.main.bundleIdentifier ?? "net.absdev.RsyncStudio")
LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
LabeledContent("Background service", value: automation.activity.title)
LabeledContent("Network", value: automation.isNetworkAvailable ? "Available" : "Offline")
}
Section("Support & Updates") {
Button("Export Diagnostics…") { exportDiagnostics() }
Text("Diagnostics contain app/macOS/rsync versions, redacted job configuration and recent failures. Private keys and credentials are never exported.")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent("Updater", value: "Signed manifest")
Button {
checkForUpdates()
} label: {
if isCheckingUpdates { ProgressView().controlSize(.small) }
else { Label("Check for Updates…", systemImage: "arrow.triangle.2.circlepath") }
}
.disabled(isCheckingUpdates)
Text("The update checker only accepts an HTTPS manifest whose payload verifies against the Ed25519 public key embedded in the signed app. It will not trust or open an unsigned update.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.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.")
}
.alert("Diagnostics", isPresented: Binding(
get: { diagnosticsMessage != nil },
set: { if !$0 { diagnosticsMessage = nil } }
)) {
Button("OK", role: .cancel) { diagnosticsMessage = nil }
} message: {
Text(diagnosticsMessage ?? "")
}
.alert("Software Update", isPresented: Binding(
get: { updateStatus != nil },
set: { if !$0 { updateStatus = nil } }
)) {
Button("OK", role: .cancel) { updateStatus = nil }
} message: {
Text(updateStatus ?? "")
}
}
private func checkForUpdates() {
isCheckingUpdates = true
Task {
let result = await SecureUpdateChecker.check()
isCheckingUpdates = false
switch result {
case .upToDate(let message): updateStatus = message
case .unavailable(let message): updateStatus = message
case .available(let message, let url):
updateStatus = message
NSWorkspace.shared.open(url)
}
}
}
private func exportDiagnostics() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.nameFieldStringValue = "Rsync Studio-Diagnostics.txt"
guard panel.runModal() == .OK, let url = panel.url else { return }
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
let os = ProcessInfo.processInfo.operatingSystemVersionString
let executable = RsyncLocator.locate()
var lines = [
"Rsync Studio Diagnostics",
"Generated: \(Date().formatted(.iso8601))",
"Version: \(version) (\(build))",
"macOS: \(os)",
"Rsync: \(executable)",
"Network: \(automation.isNetworkAvailable ? "Available" : "Offline")",
"Jobs: \(jobs.count)",
"Recent runs: \(runs.count)",
""
]
for job in jobs {
lines += [
"JOB: \(job.name)",
" Type: \(job.isRemote ? "SSH" : "Local")",
" Source: \(job.sourcePath)",
" Destination: \(job.isRemote ? "<remote path redacted>" : job.destinationPath)",
" Host: \(job.isRemote ? job.remoteHost : "-")",
" SSH key: \(job.sshKeyPath.isEmpty ? "Agent/default" : "Configured (path redacted)")",
" Schedule: \(job.scheduleDescription)",
" Watching: \(job.watchEnabled ? "Yes" : "No")",
" Last exit: \(job.lastExitCode.map(String.init) ?? "Never")",
""
]
}
let failures = runs.filter { !$0.succeeded && !$0.isRunning }.prefix(20)
if !failures.isEmpty {
lines.append("RECENT FAILURES")
for run in failures {
lines.append(" \(run.startedAt.formatted(.iso8601)) · \(run.jobName) · exit \(run.exitCode.map(String.init) ?? "unknown") · \(SyncRun.sanitizedLog(run.errorOutput).prefix(500))")
}
}
do {
try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
diagnosticsMessage = "Diagnostics exported successfully."
} catch { diagnosticsMessage = "Could not export diagnostics: \(error.localizedDescription)" }
}
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
}
}
}
private enum SecureUpdateCheckResult {
case upToDate(String)
case available(String, URL)
case unavailable(String)
}
private struct SecureUpdateManifest: Codable {
let version: String
let build: Int
let downloadURL: String
let signature: String
var signedPayload: Data {
Data("\(version)\n\(build)\n\(downloadURL)".utf8)
}
}
private enum SecureUpdateChecker {
static func check() async -> SecureUpdateCheckResult {
guard let feedString = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioUpdateManifestURL") as? String,
let feedURL = URL(string: feedString), feedURL.scheme == "https",
let keyString = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioUpdatePublicKey") as? String,
!keyString.isEmpty else {
return .unavailable("Signed updates are ready but not configured yet. Add RsyncStudioUpdateManifestURL and RsyncStudioUpdatePublicKey to the Release build after publishing your HTTPS update feed and Ed25519 public key.")
}
do {
let (data, response) = try await URLSession.shared.data(from: feedURL)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
return .unavailable("The update server returned an invalid response.")
}
let manifest = try JSONDecoder().decode(SecureUpdateManifest.self, from: data)
guard let signature = Data(base64Encoded: manifest.signature), let keyData = Data(base64Encoded: keyString) else {
return .unavailable("The update manifest or embedded public key is malformed.")
}
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData)
guard publicKey.isValidSignature(signature, for: manifest.signedPayload) else {
return .unavailable("The update manifest signature is invalid. No update was opened.")
}
guard let downloadURL = URL(string: manifest.downloadURL), downloadURL.scheme == "https" else {
return .unavailable("The signed update contains an invalid download URL.")
}
let currentBuild = Int(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0") ?? 0
if manifest.build > currentBuild {
return .available("Rsync Studio \(manifest.version) (build \(manifest.build)) is available. The signed download page will now open.", downloadURL)
}
return .upToDate("Rsync Studio is up to date.")
} catch {
return .unavailable("Could not securely check for updates: \(error.localizedDescription)")
}
}
}