paddel setup new verifier

This commit is contained in:
Mark Gregory
2026-09-09 14:40:18 +01:00
parent e802dd8cf8
commit d541f492cf
52 changed files with 176 additions and 5962 deletions
-110
View File
@@ -1,110 +0,0 @@
import Foundation
import SwiftData
@Model
final class SyncJob {
var id: UUID
var name: String
var sourcePath: String
var destinationPath: String
var isRemote: Bool
var remoteHost: String
var remoteUser: String
var remotePort: Int
var sshKeyPath: String
var archive: Bool
var compress: Bool
var deleteExtraneous: Bool
var preservePermissions: Bool
var preserveExtendedAttributes: Bool
var showProgress: Bool
var exclusionsText: String
var additionalArguments: String
var isEnabled: Bool
var createdAt: Date
var updatedAt: Date
var lastRunAt: Date?
var lastExitCode: Int?
var scheduleEnabled: Bool = false
var scheduleKindRaw: String = SyncScheduleKind.manual.rawValue
var scheduleStartAt: Date = Date().addingTimeInterval(3600)
var scheduleIntervalMinutes: Int = 30
var nextRunAt: Date? = nil
var watchEnabled: Bool = false
var watchPath: String = ""
var watchDebounceSeconds: Int = 3
var lastWatchEventAt: Date? = nil
var allowUnattendedDelete: Bool = false
var retryCount: Int = 2
var retryDelaySeconds: Int = 10
var notifyOnFailure: Bool = true
var notifyOnSuccess: Bool = false
var requireExpectedVolume: Bool = false
var expectedVolumeIdentifier: String = ""
var minimumFreeSpaceMB: Int = 512
init(
name: String = "New Sync Job",
sourcePath: String = "",
destinationPath: String = ""
) {
self.id = UUID()
self.name = name
self.sourcePath = sourcePath
self.destinationPath = destinationPath
self.isRemote = false
self.remoteHost = ""
self.remoteUser = ""
self.remotePort = 22
self.sshKeyPath = ""
self.archive = true
self.compress = false
self.deleteExtraneous = false
self.preservePermissions = true
self.preserveExtendedAttributes = true
self.showProgress = true
self.exclusionsText = ""
self.additionalArguments = ""
self.isEnabled = true
self.createdAt = Date()
self.updatedAt = Date()
self.lastRunAt = nil
self.lastExitCode = nil
self.scheduleEnabled = false
self.scheduleKindRaw = SyncScheduleKind.manual.rawValue
self.scheduleStartAt = Date().addingTimeInterval(3600)
self.scheduleIntervalMinutes = 30
self.nextRunAt = nil
self.watchEnabled = false
self.watchPath = ""
self.watchDebounceSeconds = 3
self.lastWatchEventAt = nil
self.allowUnattendedDelete = false
self.retryCount = 2
self.retryDelaySeconds = 10
self.notifyOnFailure = true
self.notifyOnSuccess = false
self.requireExpectedVolume = false
self.expectedVolumeIdentifier = ""
self.minimumFreeSpaceMB = 512
}
var exclusions: [String] {
exclusionsText
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
var expectedVolumeDisplay: String {
expectedVolumeIdentifier.isEmpty ? "Not pinned" : expectedVolumeIdentifier
}
var destinationDisplay: String {
if isRemote {
let userPrefix = remoteUser.isEmpty ? "" : "\(remoteUser)@"
return "\(userPrefix)\(remoteHost):\(destinationPath)"
}
return destinationPath
}
}
-150
View File
@@ -1,150 +0,0 @@
import Foundation
import SwiftData
@Model
final class SyncRun {
var id: UUID
var jobID: UUID
var jobName: String
var startedAt: Date
var finishedAt: Date?
var command: String
var output: String
var errorOutput: String
var exitCode: Int?
var wasDryRun: Bool
var wasCancelled: Bool
var triggerRaw: String = SyncRunTrigger.manual.rawValue
init(job: SyncJob, command: String, wasDryRun: Bool, trigger: SyncRunTrigger = .manual) {
self.id = UUID()
self.jobID = job.id
self.jobName = job.name
self.startedAt = Date()
self.command = command
self.output = ""
self.errorOutput = ""
self.exitCode = nil
self.wasDryRun = wasDryRun
self.wasCancelled = false
self.triggerRaw = trigger.rawValue
}
var trigger: SyncRunTrigger { SyncRunTrigger(rawValue: triggerRaw) ?? .manual }
var succeeded: Bool { exitCode == 0 && !wasCancelled }
var isRunning: Bool { finishedAt == nil }
var duration: TimeInterval? {
guard let finishedAt else { return nil }
return finishedAt.timeIntervalSince(startedAt)
}
var summary: RsyncSummary {
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 Studio 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 Studio Run Log\n"
text += "Job: \(jobName)\n"
text += "Started: \(startedAt.formatted(date: .complete, time: .standard))\n"
if let finishedAt { text += "Finished: \(finishedAt.formatted(date: .complete, time: .standard))\n" }
text += "Trigger: \(trigger.title)\n"
text += "Dry run: \(wasDryRun ? "Yes" : "No")\n"
text += "\nCommand\n-------\n$ \(command)\n\nOutput\n------\n"
if !output.isEmpty { text += output }
if !errorOutput.isEmpty {
if !text.hasSuffix("\n") { text += "\n" }
text += "\n--- stderr ---\n\(errorOutput)"
}
if let exitCode {
if !text.hasSuffix("\n") { text += "\n" }
text += "\nExit code: \(exitCode)\n"
}
return text
}
}
struct RsyncSummary: Equatable {
var numberOfFiles: String?
var numberOfFilesTransferred: String?
var totalFileSize: String?
var totalTransferredFileSize: String?
var unmatchedData: String?
var matchedData: String?
var fileListSize: String?
var fileListGenerationTime: String?
var fileListTransferTime: String?
var totalSent: String?
var totalReceived: String?
var transferRate: String?
var totalSizeAndSpeedup: String?
var hasStatistics: Bool {
[
numberOfFiles,
numberOfFilesTransferred,
totalFileSize,
totalTransferredFileSize,
totalSent,
totalReceived
].contains { $0 != nil }
}
static func parse(_ text: String) -> RsyncSummary {
var result = RsyncSummary()
for rawLine in text.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty else { continue }
func value(after prefix: String) -> String? {
guard line.hasPrefix(prefix) else { return nil }
return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
}
if let value = value(after: "Number of files:") { result.numberOfFiles = value; continue }
if let value = value(after: "Number of files transferred:") { result.numberOfFilesTransferred = value; continue }
if let value = value(after: "Total file size:") { result.totalFileSize = value; continue }
if let value = value(after: "Total transferred file size:") { result.totalTransferredFileSize = value; continue }
if let value = value(after: "Unmatched data:") { result.unmatchedData = value; continue }
if let value = value(after: "Matched data:") { result.matchedData = value; continue }
if let value = value(after: "File list size:") { result.fileListSize = value; continue }
if let value = value(after: "File list generation time:") { result.fileListGenerationTime = value; continue }
if let value = value(after: "File list transfer time:") { result.fileListTransferTime = value; continue }
if let value = value(after: "Total sent:") { result.totalSent = value; continue }
if let value = value(after: "Total received:") { result.totalReceived = value; continue }
if line.hasPrefix("sent "), line.contains(" bytes/sec") {
result.transferRate = line
continue
}
if line.hasPrefix("total size is "), line.contains("speedup is") {
result.totalSizeAndSpeedup = line
}
}
return result
}
}
-142
View File
@@ -1,142 +0,0 @@
import Foundation
enum SyncScheduleKind: String, CaseIterable, Identifiable, Codable {
case manual
case once
case hourly
case daily
case weekly
case monthly
case custom
var id: String { rawValue }
var title: String {
switch self {
case .manual: return "Manual only"
case .once: return "Once"
case .hourly: return "Hourly"
case .daily: return "Daily"
case .weekly: return "Weekly"
case .monthly: return "Monthly"
case .custom: return "Custom interval"
}
}
var systemImage: String {
switch self {
case .manual: return "hand.tap"
case .once: return "calendar.badge.clock"
case .hourly: return "clock.arrow.circlepath"
case .daily: return "sun.max"
case .weekly: return "calendar"
case .monthly: return "calendar.circle"
case .custom: return "timer"
}
}
}
enum SyncRunTrigger: String, Codable {
case manual
case scheduled
case watched
case syncAll
var title: String {
switch self {
case .manual: return "Manual"
case .scheduled: return "Scheduled"
case .watched: return "File change"
case .syncAll: return "Sync All"
}
}
var systemImage: String {
switch self {
case .manual: return "play.fill"
case .scheduled: return "calendar.badge.clock"
case .watched: return "eye.fill"
case .syncAll: return "arrow.triangle.2.circlepath"
}
}
}
extension SyncJob {
var scheduleKind: SyncScheduleKind {
get { SyncScheduleKind(rawValue: scheduleKindRaw) ?? .manual }
set { scheduleKindRaw = newValue.rawValue }
}
var effectiveWatchPath: String {
let trimmed = watchPath.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? sourcePath : trimmed
}
var scheduleDescription: String {
guard scheduleEnabled, scheduleKind != .manual else { return "Manual" }
let time = scheduleStartAt.formatted(date: .omitted, time: .shortened)
switch scheduleKind {
case .manual: return "Manual"
case .once: return "Once · \(scheduleStartAt.formatted(date: .abbreviated, time: .shortened))"
case .hourly: return "Every hour"
case .daily: return "Daily · \(time)"
case .weekly:
return "Weekly · \(scheduleStartAt.formatted(.dateTime.weekday(.abbreviated))) · \(time)"
case .monthly:
return "Monthly · day \(Calendar.current.component(.day, from: scheduleStartAt)) · \(time)"
case .custom:
return "Every \(max(1, scheduleIntervalMinutes)) min"
}
}
func calculateNextRun(after date: Date = Date()) -> Date? {
guard scheduleEnabled, scheduleKind != .manual else { return nil }
let calendar = Calendar.current
switch scheduleKind {
case .manual:
return nil
case .once:
return scheduleStartAt > date ? scheduleStartAt : nil
case .hourly:
return nextInterval(after: date, seconds: 3600)
case .custom:
return nextInterval(after: date, seconds: TimeInterval(max(1, scheduleIntervalMinutes) * 60))
case .daily:
let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt)
return calendar.nextDate(after: date, matching: time, matchingPolicy: .nextTime)
case .weekly:
var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt)
components.weekday = calendar.component(.weekday, from: scheduleStartAt)
return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime)
case .monthly:
var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt)
components.day = calendar.component(.day, from: scheduleStartAt)
return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime)
}
}
func occurrences(from start: Date, through end: Date, limit: Int = 400) -> [Date] {
guard scheduleEnabled, scheduleKind != .manual, start < end else { return [] }
var output: [Date] = []
var cursor = start.addingTimeInterval(-1)
if scheduleKind == .once {
return (scheduleStartAt >= start && scheduleStartAt < end) ? [scheduleStartAt] : []
}
while output.count < limit, let next = calculateNextRun(after: cursor), next < end {
if next >= start { output.append(next) }
cursor = next.addingTimeInterval(0.5)
}
return output
}
private func nextInterval(after date: Date, seconds: TimeInterval) -> Date? {
guard seconds > 0 else { return nil }
if scheduleStartAt > date { return scheduleStartAt }
let elapsed = date.timeIntervalSince(scheduleStartAt)
let steps = floor(elapsed / seconds) + 1
return scheduleStartAt.addingTimeInterval(steps * seconds)
}
}