latest updates
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user