322 lines
16 KiB
Swift
322 lines
16 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
|
|
@EnvironmentObject private var licensing: LicensingManager
|
|
@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("Buy Rsync Studio…") { openCommercialURL(infoKey: "RsyncStudioPurchaseURL") }
|
|
Button("Manage Licence Online…") { openCommercialURL(infoKey: "RsyncStudioPortalURL") }
|
|
Button("Support…") { openCommercialURL(infoKey: "RsyncStudioSupportURL") }
|
|
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") }
|
|
|
|
LicenseView()
|
|
.environmentObject(licensing)
|
|
.tabItem { Label("Licence", systemImage: "key") }
|
|
}
|
|
.frame(width: 680, height: 500)
|
|
.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 openCommercialURL(infoKey: String) {
|
|
guard let raw = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String,
|
|
let url = URL(string: raw), url.scheme == "https" else {
|
|
updateStatus = "This commercial service URL has not been configured for this build."
|
|
return
|
|
}
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|