Major update for stability

This commit is contained in:
Mark Gregory
2026-09-08 15:22:19 +01:00
parent 3b95f53a25
commit b2f3c5aef6
24 changed files with 1059 additions and 212 deletions
+28 -9
View File
@@ -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
+37 -4
View File
@@ -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)"
}
}
}
+16 -2
View File
@@ -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
}
}
}