Major update for stability
This commit is contained in:
@@ -2,12 +2,18 @@ import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
let startupStorageError: String?
|
||||
@EnvironmentObject private var automation: BackgroundSyncManager
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var selectedJob: SyncJob?
|
||||
@State private var section: AppSection = .jobs
|
||||
@State private var showingNewJob = false
|
||||
@State private var showingSplash = true
|
||||
@State private var showingStorageAlert = false
|
||||
|
||||
init(startupStorageError: String? = nil) {
|
||||
self.startupStorageError = startupStorageError
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -21,6 +27,12 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewJob) { JobEditorView() }
|
||||
.alert("Storage Recovery Mode", isPresented: $showingStorageAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(startupStorageError ?? "Rsync Manager is using temporary storage.")
|
||||
}
|
||||
.onAppear { showingStorageAlert = startupStorageError != nil }
|
||||
.task {
|
||||
guard showingSplash else { return }
|
||||
try? await Task.sleep(for: .seconds(1.0))
|
||||
@@ -124,6 +136,13 @@ struct ContentView: View {
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
}
|
||||
|
||||
if let serviceError = automation.lastError {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Color.orange)
|
||||
.help(serviceError)
|
||||
.accessibilityLabel("Background service error")
|
||||
}
|
||||
|
||||
Picker("Section", selection: $section) {
|
||||
Label("Jobs", systemImage: "rectangle.stack").tag(AppSection.jobs)
|
||||
Label("Calendar", systemImage: "calendar").tag(AppSection.calendar)
|
||||
|
||||
@@ -34,6 +34,7 @@ final class SyncJob {
|
||||
var watchPath: String = ""
|
||||
var watchDebounceSeconds: Int = 3
|
||||
var lastWatchEventAt: Date? = nil
|
||||
var allowUnattendedDelete: Bool = false
|
||||
|
||||
init(
|
||||
name: String = "New Sync Job",
|
||||
@@ -71,6 +72,7 @@ final class SyncJob {
|
||||
self.watchPath = ""
|
||||
self.watchDebounceSeconds = 3
|
||||
self.lastWatchEventAt = nil
|
||||
self.allowUnattendedDelete = false
|
||||
}
|
||||
|
||||
var exclusions: [String] {
|
||||
|
||||
@@ -43,6 +43,27 @@ final class SyncRun {
|
||||
RsyncSummary.parse(output + "\n" + errorOutput)
|
||||
}
|
||||
|
||||
|
||||
static func sanitizedLog(_ value: String, maxBytes: Int = 2_000_000) -> String {
|
||||
let stripped = stripANSI(value)
|
||||
let redacted = stripped
|
||||
.replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression)
|
||||
|
||||
let data = Data(redacted.utf8)
|
||||
guard data.count > maxBytes else { return redacted }
|
||||
let prefix = data.prefix(maxBytes)
|
||||
return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Manager for safety and storage limits.]\n"
|
||||
}
|
||||
|
||||
private static func stripANSI(_ value: String) -> String {
|
||||
value.replacingOccurrences(
|
||||
of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#,
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
|
||||
var exportText: String {
|
||||
var text = "Rsync Manager Run Log\n"
|
||||
text += "Job: \(jobName)\n"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -511,3 +511,41 @@ For an rsync-specific option not documented here, run `man rsync` in Terminal fo
|
||||
|
||||
Use **Sync All** in the Jobs sidebar to run every enabled job in sequence. Rsync Manager queues the jobs rather than starting several rsync processes at once. Each job creates its own History entry, and the header shows the current batch progress. Disabled jobs are skipped.
|
||||
|
||||
|
||||
# Security and unattended operation
|
||||
|
||||
Rsync Manager is designed for direct Developer ID distribution. It uses the macOS Hardened Runtime and intentionally does not use the App Sandbox because the utility must launch `rsync`/`ssh` and work with arbitrary user-selected paths.
|
||||
|
||||
## SSH security
|
||||
|
||||
SSH authentication uses a private key or your normal macOS SSH agent. Rsync Manager does not store SSH passwords and does not place passwords on a process command line. Automated SSH uses `BatchMode=yes`, a connection timeout, keepalive intervals and a finite keepalive failure count. Host-key verification is never disabled; connect to a new host once in Terminal first if SSH needs you to approve its host key.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local
|
||||
```
|
||||
|
||||
## Protected advanced arguments
|
||||
|
||||
The Advanced field is for ordinary rsync tuning only. Rsync Manager rejects arguments that can replace the SSH transport, run a different remote command, read options/file lists from other files, write logs/batches to arbitrary paths, load a password file, enter daemon mode, remove source files or bypass the app's controlled delete switch. Examples that are rejected include `-e`, `-M`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files` and `--delete*`. Unmatched quotes and trailing escape characters are also rejected.
|
||||
|
||||
## Delete protection
|
||||
|
||||
Manual `--delete` runs always show a destructive-operation warning. If a scheduled or file-watch job uses Delete, you must also explicitly enable **Allow unattended delete** in the job editor. This prevents a job from becoming destructively automated merely because Schedule or File Watch was enabled later.
|
||||
|
||||
Run a Dry Run and review the destination before authorising unattended delete.
|
||||
|
||||
## Path overlap protection
|
||||
|
||||
For local jobs, source and destination must be separate directory trees. Rsync Manager rejects configurations where the destination is inside the source or the source is inside the destination. This prevents recursive backup trees and common file-watch loops.
|
||||
|
||||
## History and privacy
|
||||
|
||||
Rsync Manager does not include analytics or tracking. Run output is sanitised before persistence, terminal control sequences are removed, likely credential-shaped values are redacted, and each stdout/stderr log is capped at approximately 2 MB. History retention is configurable in **Settings > Automation > History & Privacy** and defaults to 90 days.
|
||||
|
||||
The bundled privacy manifest declares no tracking and no collected data.
|
||||
|
||||
## Release verification
|
||||
|
||||
The project contains `Docs/RELEASE.md` and `Scripts/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftData
|
||||
@MainActor
|
||||
struct RsyncManagerApp: App {
|
||||
let sharedModelContainer: ModelContainer
|
||||
let startupStorageError: String?
|
||||
@StateObject private var runner: RsyncRunner
|
||||
@StateObject private var automation: BackgroundSyncManager
|
||||
|
||||
@@ -14,20 +15,33 @@ struct RsyncManagerApp: App {
|
||||
SyncRun.self,
|
||||
])
|
||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
KeychainStore.delete("defaultSSHPassword")
|
||||
|
||||
do {
|
||||
let container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
sharedModelContainer = container
|
||||
startupStorageError = nil
|
||||
let rsyncRunner = RsyncRunner()
|
||||
_runner = StateObject(wrappedValue: rsyncRunner)
|
||||
_automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: container.mainContext, runner: rsyncRunner))
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
let fallbackConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
do {
|
||||
let fallback = try ModelContainer(for: schema, configurations: [fallbackConfiguration])
|
||||
sharedModelContainer = fallback
|
||||
startupStorageError = "Rsync Manager could not open its persistent database and is running with temporary in-memory storage for this session. Jobs or history created now will not persist. Original error: \(error.localizedDescription)"
|
||||
let rsyncRunner = RsyncRunner()
|
||||
_runner = StateObject(wrappedValue: rsyncRunner)
|
||||
_automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: fallback.mainContext, runner: rsyncRunner))
|
||||
} catch {
|
||||
fatalError("Rsync Manager could not initialise persistent or temporary storage: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
ContentView(startupStorageError: startupStorageError)
|
||||
.frame(minWidth: 1040, minHeight: 680)
|
||||
.environmentObject(runner)
|
||||
.environmentObject(automation)
|
||||
|
||||
@@ -23,6 +23,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
@Published private(set) var isSyncAllRunning = false
|
||||
@Published private(set) var syncAllCompleted = 0
|
||||
@Published private(set) var syncAllTotal = 0
|
||||
@Published private(set) var lastError: String?
|
||||
|
||||
var activityDisplayTitle: String {
|
||||
if isSyncAllRunning {
|
||||
@@ -67,6 +68,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
refreshConfiguration()
|
||||
pruneHistoryIfNeeded(force: true)
|
||||
checkSchedules()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -119,7 +121,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil {
|
||||
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("refreshing automation configuration")
|
||||
}
|
||||
|
||||
private func checkSchedules() {
|
||||
@@ -136,7 +138,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
job.nextRunAt = job.calculateNextRun(after: now)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("updating scheduled jobs")
|
||||
}
|
||||
|
||||
private func fileChanged(jobID: UUID, path: String) {
|
||||
@@ -150,7 +152,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
_ = self.enqueue(jobID: jobID, trigger: .watched)
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("recording file-watch activity")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -209,12 +211,12 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable)
|
||||
let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger)
|
||||
modelContext.insert(run)
|
||||
try? modelContext.save()
|
||||
saveContext("creating run history")
|
||||
|
||||
do {
|
||||
let result = try await runner.run(job: job, dryRun: false)
|
||||
run.output = result.stdout
|
||||
run.errorOutput = result.stderr
|
||||
let result = try await runner.run(job: job, dryRun: false, trigger: trigger)
|
||||
run.output = SyncRun.sanitizedLog(result.stdout)
|
||||
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
|
||||
run.exitCode = Int(result.exitCode)
|
||||
run.wasCancelled = result.wasCancelled
|
||||
run.finishedAt = Date()
|
||||
@@ -222,13 +224,42 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
job.lastExitCode = Int(result.exitCode)
|
||||
job.updatedAt = Date()
|
||||
} catch {
|
||||
run.errorOutput = error.localizedDescription
|
||||
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
|
||||
run.exitCode = -1
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = -1
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("saving run history")
|
||||
pruneHistoryIfNeeded()
|
||||
}
|
||||
|
||||
private func pruneHistoryIfNeeded(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force, let last = UserDefaults.standard.object(forKey: "lastHistoryPruneAt") as? Date, now.timeIntervalSince(last) < 3600 { return }
|
||||
|
||||
let configuredDays = UserDefaults.standard.integer(forKey: "historyRetentionDays")
|
||||
let days = configuredDays == 0 ? 90 : max(7, min(configuredDays, 3650))
|
||||
let cutoff = now.addingTimeInterval(-TimeInterval(days) * 86_400)
|
||||
|
||||
do {
|
||||
let runs = try modelContext.fetch(FetchDescriptor<SyncRun>())
|
||||
for run in runs where run.finishedAt != nil && run.startedAt < cutoff {
|
||||
modelContext.delete(run)
|
||||
}
|
||||
try modelContext.save()
|
||||
UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt")
|
||||
} catch {
|
||||
lastError = "Could not prune run history: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContext(_ operation: String) {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
lastError = "Database error while \(operation): \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchJobs() -> [SyncJob] {
|
||||
|
||||
@@ -12,31 +12,16 @@ struct RsyncCommandBuilder {
|
||||
|
||||
if job.compress { args.append("-z") }
|
||||
|
||||
// Apple's bundled /usr/bin/rsync uses the Apple-specific -E flag for
|
||||
// extended attributes. That flag is not compatible with a standard
|
||||
// rsync server over SSH (for example Linux rsync 3.x), because Apple
|
||||
// rsync expands it to --extended-attributes on the remote side.
|
||||
//
|
||||
// Therefore:
|
||||
// • Apple rsync + local job -> -E
|
||||
// • Apple rsync + SSH job -> omit xattr flag
|
||||
// • Modern/Homebrew rsync -> -X (standard rsync xattrs)
|
||||
if job.preserveExtendedAttributes {
|
||||
if isAppleRsync(executable) {
|
||||
if !job.isRemote {
|
||||
args.append("-E")
|
||||
}
|
||||
if !job.isRemote { args.append("-E") }
|
||||
} else {
|
||||
args.append("-X")
|
||||
}
|
||||
}
|
||||
|
||||
if job.deleteExtraneous { args.append("--delete") }
|
||||
|
||||
// --info=progress2 is not supported by Apple's bundled rsync 2.6.9.
|
||||
// --progress works on both Apple's rsync and modern Homebrew rsync.
|
||||
if job.showProgress { args.append("--progress") }
|
||||
|
||||
if dryRun { args.append("--dry-run") }
|
||||
|
||||
args += ["--human-readable", "--stats"]
|
||||
@@ -50,14 +35,24 @@ struct RsyncCommandBuilder {
|
||||
}
|
||||
|
||||
if job.isRemote {
|
||||
var sshParts = ["ssh", "-p", String(job.remotePort)]
|
||||
var sshParts = [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=15",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", String(job.remotePort)
|
||||
]
|
||||
let keyPath = normalizedPrivateKeyPath(job.sshKeyPath)
|
||||
if !keyPath.isEmpty {
|
||||
sshParts += ["-i", keyPath]
|
||||
sshParts += ["-o", "IdentitiesOnly=yes", "-i", keyPath]
|
||||
}
|
||||
args += ["-e", sshParts.map(shellEscape).joined(separator: " ")]
|
||||
}
|
||||
|
||||
// End option parsing before user-controlled filesystem operands. This prevents
|
||||
// a local path beginning with '-' from being interpreted as another rsync option.
|
||||
args.append("--")
|
||||
args.append(normalizeSource(job.sourcePath))
|
||||
args.append(job.destinationDisplay)
|
||||
return args
|
||||
@@ -71,22 +66,21 @@ struct RsyncCommandBuilder {
|
||||
|
||||
static func normalizedPrivateKeyPath(_ path: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasSuffix(".pub") else { return trimmed }
|
||||
guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath }
|
||||
|
||||
let privatePath = String(trimmed.dropLast(4))
|
||||
if FileManager.default.fileExists(atPath: privatePath) {
|
||||
return privatePath
|
||||
let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath
|
||||
if FileManager.default.fileExists(atPath: expandedPrivatePath) {
|
||||
return expandedPrivatePath
|
||||
}
|
||||
return trimmed
|
||||
return (trimmed as NSString).expandingTildeInPath
|
||||
}
|
||||
|
||||
private static func isAppleRsync(_ executable: String) -> Bool {
|
||||
executable == "/usr/bin/rsync"
|
||||
}
|
||||
|
||||
private static func normalizeSource(_ path: String) -> String {
|
||||
path
|
||||
}
|
||||
private static func normalizeSource(_ path: String) -> String { path }
|
||||
|
||||
static func shellEscape(_ value: String) -> String {
|
||||
if value.isEmpty { return "''" }
|
||||
@@ -130,3 +124,169 @@ struct RsyncCommandBuilder {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum RsyncSecurityValidator {
|
||||
static func validationMessage(for job: SyncJob, trigger: SyncRunTrigger = .manual) -> String? {
|
||||
let source = job.sourcePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !source.isEmpty, !destination.isEmpty else {
|
||||
return "Source and destination are required."
|
||||
}
|
||||
|
||||
if containsControlCharacters(source) || containsControlCharacters(destination) {
|
||||
return "Paths may not contain control characters."
|
||||
}
|
||||
|
||||
if job.watchEnabled && containsControlCharacters(job.effectiveWatchPath) {
|
||||
return "The file-watch path may not contain control characters."
|
||||
}
|
||||
|
||||
if job.exclusions.contains(where: containsControlCharacters) {
|
||||
return "Exclusion patterns may not contain control characters."
|
||||
}
|
||||
|
||||
if let advancedError = validateAdditionalArguments(job.additionalArguments) {
|
||||
return advancedError
|
||||
}
|
||||
|
||||
if job.isRemote {
|
||||
let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = job.remoteUser.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard isValidHost(host) else {
|
||||
return "The SSH host is invalid. Use a hostname or IP address without spaces or command-line option prefixes."
|
||||
}
|
||||
if !user.isEmpty, !isValidUser(user) {
|
||||
return "The SSH username is invalid. Use letters, numbers, '.', '_' or '-'."
|
||||
}
|
||||
guard (1...65535).contains(job.remotePort) else {
|
||||
return "The SSH port must be between 1 and 65535."
|
||||
}
|
||||
if destination.hasPrefix("-") {
|
||||
return "A remote destination may not begin with '-'."
|
||||
}
|
||||
if !isSafeRemotePath(destination) {
|
||||
return "The remote destination contains characters that are unsafe for an SSH/rsync remote shell. Use a normal path containing letters, numbers, spaces, '/', '.', '_', '-', '~', '+', '@', '%' or '=' only."
|
||||
}
|
||||
if destination == host || destination == "\(user)@\(host)" {
|
||||
return "Remote destination must be a path on the remote machine, not the host address."
|
||||
}
|
||||
|
||||
let keyPath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath)
|
||||
if !job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if keyPath.hasSuffix(".pub") {
|
||||
return "Select the private SSH key, not the .pub public key."
|
||||
}
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: keyPath, isDirectory: &isDirectory), !isDirectory.boolValue else {
|
||||
return "The selected private SSH key does not exist or is not a regular file."
|
||||
}
|
||||
}
|
||||
} else if pathsOverlap(source: source, destination: destination) {
|
||||
return "Source and destination overlap. Choose separate directory trees to prevent recursive copies or file-watch loops."
|
||||
}
|
||||
|
||||
if job.deleteExtraneous,
|
||||
(trigger == .scheduled || trigger == .watched),
|
||||
!job.allowUnattendedDelete {
|
||||
return "This job uses --delete but unattended destructive sync is not authorised. Edit the job and explicitly enable unattended delete."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func validateAdditionalArguments(_ raw: String) -> String? {
|
||||
if !hasBalancedShellSyntax(raw) {
|
||||
return "Advanced rsync arguments contain an unmatched quote or trailing escape character."
|
||||
}
|
||||
|
||||
let arguments = RsyncCommandBuilder.shellSplit(raw)
|
||||
let blockedExact: Set<String> = [
|
||||
"-e", "-M", "--rsh", "--rsync-path", "--remote-option",
|
||||
"--password-file", "--daemon", "--config", "--port", "--address",
|
||||
"--sockopts", "--remove-source-files", "--copy-as", "--files-from",
|
||||
"--exclude-from", "--include-from", "--log-file", "--log-file-format",
|
||||
"--backup-dir", "--temp-dir", "--partial-dir", "--chown", "--usermap",
|
||||
"--groupmap", "--rsync-path", "--write-batch", "--only-write-batch",
|
||||
"--read-batch"
|
||||
]
|
||||
let blockedPrefixes = [
|
||||
"--rsh=", "--rsync-path=", "--remote-option=", "--password-file=",
|
||||
"--config=", "--port=", "--address=", "--sockopts=", "--copy-as=",
|
||||
"--delete", "--remove-source-files", "--files-from=", "--exclude-from=",
|
||||
"--include-from=", "--log-file=", "--log-file-format=", "--backup-dir=",
|
||||
"--temp-dir=", "--partial-dir=", "--chown=", "--usermap=", "--groupmap=",
|
||||
"--write-batch=", "--only-write-batch=", "--read-batch="
|
||||
]
|
||||
|
||||
for argument in arguments {
|
||||
if blockedExact.contains(argument) || blockedPrefixes.contains(where: { argument.hasPrefix($0) }) {
|
||||
return "Advanced argument '\(argument)' is security-sensitive or destructive and is managed by Rsync Manager. Remove it and use the dedicated job controls instead."
|
||||
}
|
||||
if containsControlCharacters(argument) {
|
||||
return "Advanced rsync arguments may not contain control characters."
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
private static func isSafeRemotePath(_ value: String) -> Bool {
|
||||
guard !value.isEmpty, !containsControlCharacters(value) else { return false }
|
||||
// Rsync invokes the remote side through SSH. Keep remote paths out of shell
|
||||
// metacharacter territory so a configured path can never become a remote command.
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " /._-~+@%="))
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func hasBalancedShellSyntax(_ value: String) -> Bool {
|
||||
var quote: Character?
|
||||
var escaping = false
|
||||
for ch in value {
|
||||
if escaping {
|
||||
escaping = false
|
||||
continue
|
||||
}
|
||||
if ch == "\\" {
|
||||
escaping = true
|
||||
continue
|
||||
}
|
||||
if let activeQuote = quote {
|
||||
if ch == activeQuote { quote = nil }
|
||||
continue
|
||||
}
|
||||
if ch == "\"" || ch == "'" { quote = ch }
|
||||
}
|
||||
return quote == nil && !escaping
|
||||
}
|
||||
|
||||
private static func isValidHost(_ value: String) -> Bool {
|
||||
guard !value.isEmpty, !value.hasPrefix("-"), !containsControlCharacters(value) else { return false }
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_:[]"))
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func isValidUser(_ value: String) -> Bool {
|
||||
guard !value.hasPrefix("-"), !containsControlCharacters(value) else { return false }
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func containsControlCharacters(_ value: String) -> Bool {
|
||||
value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) }
|
||||
}
|
||||
|
||||
private static func pathsOverlap(source: String, destination: String) -> Bool {
|
||||
let src = canonicalLocalPath(source)
|
||||
let dst = canonicalLocalPath(destination)
|
||||
guard !src.isEmpty, !dst.isEmpty else { return false }
|
||||
if src == dst { return true }
|
||||
return dst.hasPrefix(src + "/") || src.hasPrefix(dst + "/")
|
||||
}
|
||||
|
||||
private static func canonicalLocalPath(_ value: String) -> String {
|
||||
let expanded = (value as NSString).expandingTildeInPath
|
||||
let standardized = (expanded as NSString).standardizingPath
|
||||
if standardized == "/" { return standardized }
|
||||
return standardized.hasSuffix("/") ? String(standardized.dropLast()) : standardized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
enum RsyncLocator {
|
||||
static let candidates = [
|
||||
"/opt/homebrew/bin/rsync",
|
||||
"/usr/local/bin/rsync",
|
||||
"/usr/bin/rsync"
|
||||
]
|
||||
|
||||
static func locate() -> String {
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/rsync",
|
||||
"/usr/local/bin/rsync",
|
||||
"/usr/bin/rsync"
|
||||
]
|
||||
return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) ?? "/usr/bin/rsync"
|
||||
candidates.first(where: isTrustedExecutableCandidate) ?? "/usr/bin/rsync"
|
||||
}
|
||||
|
||||
static func isTrustedExecutableCandidate(_ path: String) -> Bool {
|
||||
let manager = FileManager.default
|
||||
guard manager.isExecutableFile(atPath: path) else { return false }
|
||||
|
||||
let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
|
||||
var isDirectory: ObjCBool = false
|
||||
guard manager.fileExists(atPath: resolved, isDirectory: &isDirectory), !isDirectory.boolValue else { return false }
|
||||
|
||||
do {
|
||||
let attributes = try manager.attributesOfItem(atPath: resolved)
|
||||
return attributes[.type] as? FileAttributeType == .typeRegular
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,16 @@ final class RsyncRunner: ObservableObject {
|
||||
let wasCancelled: Bool
|
||||
}
|
||||
|
||||
func run(job: SyncJob, dryRun: Bool) async throws -> Result {
|
||||
func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) async throws -> Result {
|
||||
guard !isRunning else { throw RunnerError.alreadyRunning }
|
||||
guard !job.sourcePath.isEmpty, !job.destinationPath.isEmpty else { throw RunnerError.invalidPaths }
|
||||
if job.isRemote && job.remoteHost.isEmpty { throw RunnerError.missingRemoteHost }
|
||||
if job.isRemote && remoteDestinationLooksLikeHost(job) { throw RunnerError.invalidRemoteDestination }
|
||||
if job.isRemote && job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".pub") {
|
||||
let privatePath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath)
|
||||
if privatePath.hasSuffix(".pub") { throw RunnerError.publicSSHKeySelected }
|
||||
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: trigger) {
|
||||
throw RunnerError.securityValidation(message)
|
||||
}
|
||||
|
||||
let executable = RsyncLocator.locate()
|
||||
guard RsyncLocator.isTrustedExecutableCandidate(executable) else {
|
||||
throw RunnerError.invalidExecutable(executable)
|
||||
}
|
||||
let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable)
|
||||
|
||||
isRunning = true
|
||||
@@ -106,15 +105,18 @@ final class RsyncRunner: ObservableObject {
|
||||
}
|
||||
|
||||
enum RunnerError: LocalizedError {
|
||||
case alreadyRunning, invalidPaths, missingRemoteHost, invalidRemoteDestination, publicSSHKeySelected
|
||||
case alreadyRunning
|
||||
case securityValidation(String)
|
||||
case invalidExecutable(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .alreadyRunning: return "Another rsync job is already running."
|
||||
case .invalidPaths: return "Source and destination are required."
|
||||
case .missingRemoteHost: return "Remote host is required for an SSH job."
|
||||
case .invalidRemoteDestination: return "Remote destination must be a path on the remote machine, for example /home/mag/backup or ~/backup — not the host address."
|
||||
case .publicSSHKeySelected: return "The SSH key must be the private key (for example ~/.ssh/id_rsa or ~/.ssh/id_ed25519), not the .pub file."
|
||||
case .alreadyRunning:
|
||||
return "Another rsync job is already running."
|
||||
case .securityValidation(let message):
|
||||
return message
|
||||
case .invalidExecutable(let path):
|
||||
return "The rsync executable at \(path) is unavailable or is not a trusted regular executable file."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ struct SettingsView: View {
|
||||
@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
|
||||
@AppStorage("historyRetentionDays") private var historyRetentionDays = 90
|
||||
@State private var launchAtLogin = false
|
||||
@State private var launchError: String?
|
||||
|
||||
@@ -30,18 +29,9 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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") {
|
||||
@@ -71,6 +61,13 @@ struct SettingsView: View {
|
||||
.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)
|
||||
@@ -91,7 +88,16 @@ struct SettingsView: View {
|
||||
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.")
|
||||
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)
|
||||
}
|
||||
@@ -107,11 +113,9 @@ struct SettingsView: View {
|
||||
}
|
||||
.frame(width: 650, height: 455)
|
||||
.onAppear {
|
||||
defaultPassword = KeychainStore.string(for: "defaultSSHPassword")
|
||||
passwordSaved = !defaultPassword.isEmpty
|
||||
KeychainStore.delete("defaultSSHPassword")
|
||||
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(
|
||||
|
||||
@@ -19,23 +19,37 @@ enum KeychainStore {
|
||||
return value
|
||||
}
|
||||
|
||||
static func set(_ value: String, for account: String) {
|
||||
@discardableResult
|
||||
static func set(_ value: String, for account: String) -> OSStatus {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
if value.isEmpty {
|
||||
SecItemDelete(base as CFDictionary)
|
||||
return
|
||||
}
|
||||
if value.isEmpty { return delete(account) }
|
||||
|
||||
let data = Data(value.utf8)
|
||||
let update: [String: Any] = [kSecValueData as String: data]
|
||||
if SecItemUpdate(base as CFDictionary, update as CFDictionary) == errSecItemNotFound {
|
||||
var add = base
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
let update: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
||||
]
|
||||
let updateStatus = SecItemUpdate(base as CFDictionary, update as CFDictionary)
|
||||
if updateStatus != errSecItemNotFound { return updateStatus }
|
||||
|
||||
var add = base
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
|
||||
return SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func delete(_ account: String) -> OSStatus {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecItemNotFound ? errSecSuccess : status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,10 @@ struct JobDetailView: View {
|
||||
Binding(
|
||||
get: { job.scheduleEnabled },
|
||||
set: { enabled in
|
||||
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
|
||||
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling."
|
||||
return
|
||||
}
|
||||
job.scheduleEnabled = enabled
|
||||
if enabled {
|
||||
if job.scheduleKind == .manual { job.scheduleKind = .daily }
|
||||
@@ -268,6 +272,10 @@ struct JobDetailView: View {
|
||||
Binding(
|
||||
get: { job.watchEnabled },
|
||||
set: { enabled in
|
||||
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
|
||||
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching."
|
||||
return
|
||||
}
|
||||
job.watchEnabled = enabled
|
||||
if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
job.watchPath = job.sourcePath
|
||||
@@ -284,8 +292,12 @@ struct JobDetailView: View {
|
||||
|
||||
private func persistAutomationChange() {
|
||||
job.updatedAt = Date()
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
do {
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
} catch {
|
||||
errorMessage = "Could not save automation settings: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -316,7 +328,12 @@ struct JobDetailView: View {
|
||||
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable)
|
||||
let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual)
|
||||
modelContext.insert(run)
|
||||
try? modelContext.save()
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
errorMessage = "Could not create run history: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
if presentation.showsModal {
|
||||
presentedRun = run
|
||||
@@ -325,23 +342,25 @@ struct JobDetailView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await runner.run(job: job, dryRun: dryRun)
|
||||
run.output = result.stdout
|
||||
run.errorOutput = result.stderr
|
||||
let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual)
|
||||
run.output = SyncRun.sanitizedLog(result.stdout)
|
||||
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
|
||||
run.exitCode = Int(result.exitCode)
|
||||
run.wasCancelled = result.wasCancelled
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = Int(result.exitCode)
|
||||
job.updatedAt = Date()
|
||||
try? modelContext.save()
|
||||
do { try modelContext.save() }
|
||||
catch { errorMessage = "Could not save run history: \(error.localizedDescription)" }
|
||||
} catch {
|
||||
run.errorOutput = error.localizedDescription
|
||||
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
|
||||
run.exitCode = -1
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = -1
|
||||
try? modelContext.save()
|
||||
do { try modelContext.save() }
|
||||
catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" }
|
||||
|
||||
if !presentation.showsModal {
|
||||
errorMessage = error.localizedDescription
|
||||
|
||||
@@ -38,6 +38,8 @@ struct JobEditorView: View {
|
||||
@State private var watchEnabled: Bool
|
||||
@State private var watchPath: String
|
||||
@State private var watchDebounceSeconds: Int
|
||||
@State private var allowUnattendedDelete: Bool
|
||||
@State private var validationMessage: String?
|
||||
|
||||
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
|
||||
self.existingJob = job
|
||||
@@ -68,6 +70,8 @@ struct JobEditorView: View {
|
||||
_watchPath = State(initialValue: job?.watchPath ?? "")
|
||||
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
|
||||
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
|
||||
_allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false)
|
||||
_validationMessage = State(initialValue: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -174,6 +178,12 @@ struct JobEditorView: View {
|
||||
Toggle("Archive mode (-a)", isOn: $archive)
|
||||
Toggle("Compress during transfer (-z)", isOn: $compress)
|
||||
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
|
||||
if deleteExtraneous && (scheduleEnabled || watchEnabled) {
|
||||
Toggle("Allow unattended delete", isOn: $allowUnattendedDelete)
|
||||
Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Toggle("Preserve permissions", isOn: $preservePermissions)
|
||||
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
|
||||
Toggle("Show transfer progress", isOn: $showProgress)
|
||||
@@ -191,6 +201,9 @@ struct JobEditorView: View {
|
||||
Section("Advanced") {
|
||||
TextField("Additional rsync arguments", text: $additionalArguments)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -213,6 +226,14 @@ struct JobEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Invalid Sync Job", isPresented: Binding(
|
||||
get: { validationMessage != nil },
|
||||
set: { if !$0 { validationMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { validationMessage = nil }
|
||||
} message: {
|
||||
Text(validationMessage ?? "The job configuration is invalid.")
|
||||
}
|
||||
.frame(width: 690, height: 820)
|
||||
}
|
||||
|
||||
@@ -262,10 +283,22 @@ struct JobEditorView: View {
|
||||
job.watchEnabled = watchEnabled
|
||||
job.watchPath = watchPath
|
||||
job.watchDebounceSeconds = watchDebounceSeconds
|
||||
job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false
|
||||
job.updatedAt = Date()
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
|
||||
let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual
|
||||
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) {
|
||||
validationMessage = message
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "Could not save the job: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct JobListView: View {
|
||||
@Binding var showingNewJob: Bool
|
||||
@State private var searchText = ""
|
||||
@State private var filter: JobFilter = .all
|
||||
@State private var persistenceError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -50,6 +51,14 @@ struct JobListView: View {
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
}
|
||||
.alert("Database Error", isPresented: Binding(
|
||||
get: { persistenceError != nil },
|
||||
set: { if !$0 { persistenceError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { persistenceError = nil }
|
||||
} message: {
|
||||
Text(persistenceError ?? "Unable to save job changes.")
|
||||
}
|
||||
}
|
||||
|
||||
private var dashboardHeader: some View {
|
||||
@@ -198,6 +207,7 @@ struct JobListView: View {
|
||||
copy.watchEnabled = job.watchEnabled
|
||||
copy.watchPath = job.watchPath
|
||||
copy.watchDebounceSeconds = job.watchDebounceSeconds
|
||||
copy.allowUnattendedDelete = job.allowUnattendedDelete
|
||||
modelContext.insert(copy)
|
||||
saveAndRefresh()
|
||||
selection = copy
|
||||
@@ -210,8 +220,12 @@ struct JobListView: View {
|
||||
}
|
||||
|
||||
private func saveAndRefresh() {
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
do {
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
} catch {
|
||||
persistenceError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ struct RunHistoryView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
|
||||
@State private var selection: SyncRun?
|
||||
@State private var persistenceError: String?
|
||||
let onBack: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -24,7 +25,7 @@ struct RunHistoryView: View {
|
||||
Button("Clear", role: .destructive) {
|
||||
runs.forEach(modelContext.delete)
|
||||
selection = nil
|
||||
try? modelContext.save()
|
||||
saveHistoryChanges()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -79,7 +80,7 @@ struct RunHistoryView: View {
|
||||
Button("Delete Log", role: .destructive) {
|
||||
if selection?.id == run.id { selection = nil }
|
||||
modelContext.delete(run)
|
||||
try? modelContext.save()
|
||||
saveHistoryChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +104,19 @@ struct RunHistoryView: View {
|
||||
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.alert("Database Error", isPresented: Binding(
|
||||
get: { persistenceError != nil },
|
||||
set: { if !$0 { persistenceError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { persistenceError = nil }
|
||||
} message: {
|
||||
Text(persistenceError ?? "Unable to update run history.")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveHistoryChanges() {
|
||||
do { try modelContext.save() }
|
||||
catch { persistenceError = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func statusIcon(_ run: SyncRun) -> String {
|
||||
|
||||
Reference in New Issue
Block a user