diff --git a/Rsync Studio.xcodeproj/project.pbxproj b/Rsync Studio.xcodeproj/project.pbxproj index b01280e..f1dcee8 100644 --- a/Rsync Studio.xcodeproj/project.pbxproj +++ b/Rsync Studio.xcodeproj/project.pbxproj @@ -253,11 +253,10 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "Rsync Studio/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Rsync Studio"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.stage.absdev.net/api/"; + INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.vm/api/"; INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k="; INFOPLIST_KEY_RsyncStudioPortalURL = ""; INFOPLIST_KEY_RsyncStudioPurchaseURL = ""; @@ -266,6 +265,7 @@ INFOPLIST_KEY_RsyncStudioUpdatePublicKey = ""; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncStudio; + INFOPLIST_FILE = "Rsync Studio/Info.plist"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -366,11 +366,10 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "Rsync Studio/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Rsync Studio"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.stage.absdev.net/api/"; + INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.vm/api/"; INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k="; INFOPLIST_KEY_RsyncStudioPortalURL = ""; INFOPLIST_KEY_RsyncStudioPurchaseURL = ""; @@ -379,6 +378,7 @@ INFOPLIST_KEY_RsyncStudioUpdatePublicKey = ""; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncStudio; + INFOPLIST_FILE = "Rsync Studio/Info.plist"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/Rsync Studio/Assets.xcassets/AccentColor.colorset/Contents.json b/Rsync Studio/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..740fb4f --- /dev/null +++ b/Rsync Studio/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0.078", + "green": "0.455", + "blue": "0.902", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/Contents.json b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ccbe64b --- /dev/null +++ b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "idiom": "mac", + "size": "16x16", + "scale": "1x", + "filename": "appicon_16x16@1x.png" + }, + { + "idiom": "mac", + "size": "16x16", + "scale": "2x", + "filename": "appicon_16x16@2x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "1x", + "filename": "appicon_32x32@1x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "2x", + "filename": "appicon_32x32@2x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "1x", + "filename": "appicon_128x128@1x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "2x", + "filename": "appicon_128x128@2x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "1x", + "filename": "appicon_256x256@1x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "2x", + "filename": "appicon_256x256@2x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "1x", + "filename": "appicon_512x512@1x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "2x", + "filename": "appicon_512x512@2x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png new file mode 100644 index 0000000..0166945 Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png new file mode 100644 index 0000000..0fce1cf Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png new file mode 100644 index 0000000..de6a01b Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png new file mode 100644 index 0000000..37b29b9 Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png new file mode 100644 index 0000000..0fce1cf Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png new file mode 100644 index 0000000..75e61eb Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png new file mode 100644 index 0000000..37b29b9 Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png new file mode 100644 index 0000000..be6fbfa Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png new file mode 100644 index 0000000..75e61eb Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png new file mode 100644 index 0000000..f11c1f3 Binary files /dev/null and b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png differ diff --git a/Rsync Studio/Assets.xcassets/Contents.json b/Rsync Studio/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/Rsync Studio/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rsync Studio/Info.plist b/Rsync Studio/Info.plist new file mode 100644 index 0000000..5450062 --- /dev/null +++ b/Rsync Studio/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + + RsyncStudioLicenseAPIBaseURL + https://rsync.stage.absdev.net/api/ + RsyncStudioLicensePublicKey + /QWCMp3he0bUM0VIYYmH9y1/BCncpUo4h/J8sM8gPKg= + RsyncStudioPurchaseURL + https://rsync.stage.absdev.net/buy + RsyncStudioPortalURL + https://rsync.stage.absdev.net/portal + RsyncStudioSupportURL + https://rsync.stage.absdev.net/support + + diff --git a/Rsync Studio/Models/SyncJob.swift b/Rsync Studio/Models/SyncJob.swift new file mode 100644 index 0000000..a64f6ef --- /dev/null +++ b/Rsync Studio/Models/SyncJob.swift @@ -0,0 +1,110 @@ +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 + } +} diff --git a/Rsync Studio/Models/SyncRun.swift b/Rsync Studio/Models/SyncRun.swift new file mode 100644 index 0000000..378c078 --- /dev/null +++ b/Rsync Studio/Models/SyncRun.swift @@ -0,0 +1,150 @@ +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 + } +} diff --git a/Rsync Studio/Models/SyncSchedule.swift b/Rsync Studio/Models/SyncSchedule.swift new file mode 100644 index 0000000..0a13809 --- /dev/null +++ b/Rsync Studio/Models/SyncSchedule.swift @@ -0,0 +1,142 @@ +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) + } +} diff --git a/Rsync Studio/Resources/PrivacyInfo.xcprivacy b/Rsync Studio/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..937278f --- /dev/null +++ b/Rsync Studio/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/Rsync Studio/Resources/Rsync Studio Help.md b/Rsync Studio/Resources/Rsync Studio Help.md new file mode 100644 index 0000000..91baef3 --- /dev/null +++ b/Rsync Studio/Resources/Rsync Studio Help.md @@ -0,0 +1,575 @@ +# Rsync Studio Help + +Rsync Studio is a macOS front end for `rsync`. It can run local copies, SSH-based remote synchronisation, dry runs, scheduled jobs, and file/folder watch jobs. Every run is recorded in History with the command, result and transfer statistics. + +## 1. Before You Start + +Rsync Studio looks for rsync in this order: + +1. `/opt/homebrew/bin/rsync` +2. `/usr/local/bin/rsync` +3. `/usr/bin/rsync` + +For the best compatibility with modern Linux servers, install a current rsync using Homebrew. The macOS supplied `/usr/bin/rsync` is older and behaves differently for some extended-attribute options. + +SSH jobs also require the normal macOS `ssh` client and network access to the remote computer. + +## 2. Create a Local Sync Job + +1. Select **New Sync Job**. +2. Give the job a descriptive name, for example `Documents Backup`. +3. Choose **Local** as the destination type. +4. Select the source folder. +5. Select the destination folder. +6. Choose the required rsync options. +7. Save the job. +8. Run **Dry Run** first, then use **Run Now** when the result looks correct. + +### Example + +Source: + +`/Users/mag/Documents/` + +Destination: + +`/Volumes/Backup/Documents/` + +A typical job using Archive mode is equivalent to: + +`rsync -a --human-readable --stats /Users/mag/Documents/ /Volumes/Backup/Documents/` + +### Trailing slash behaviour + +Rsync treats these differently: + +`/Users/mag/Documents/` + +copies the **contents** of Documents into the destination. + +`/Users/mag/Documents` + +copies the **Documents directory itself** into the destination. + +Check the source carefully before enabling Delete mode. + +## 3. Create an SSH Remote Job + +1. Create or edit a job. +2. Select **SSH Remote**. +3. Enter the remote host, for example `192.168.1.2` or `pegasus.local`. +4. Enter the SSH username, for example `mag`. +5. Set the SSH port, normally `22`. +6. Select a **private** SSH key if required. +7. Enter a remote destination path such as `/home/mag/backups/Documents`. +8. Save and run a Dry Run. + +### Correct remote example + +Host: `pegasus.local` + +Username: `mag` + +Destination: `/home/mag/backups/photos` + +The resulting destination is similar to: + +`mag@pegasus.local:/home/mag/backups/photos` + +### Incorrect remote destination + +Do not put the host address in the Destination field. These are wrong: + +`192.168.1.2` + +`mag@192.168.1.2` + +The Destination field must contain a path on the remote machine. + +## 4. SSH Keys + +Use the private key, not the `.pub` file. + +Correct examples: + +`~/.ssh/id_ed25519` + +`~/.ssh/id_rsa` + +Incorrect: + +`~/.ssh/id_ed25519.pub` + +If you accidentally select a `.pub` key and the matching private key exists beside it, Rsync Studio attempts to use the private key. If only the public key exists, the run is rejected. + +### Test SSH outside the app + +Before troubleshooting rsync, verify SSH itself works: + +`ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local` + +If that command cannot connect, fix SSH/network/authentication first. + +## 5. Default Connection Settings + +Open **Rsync Studio > Settings > Connections** to configure defaults for new SSH jobs: + +- Default username +- Default host +- Default SSH port +- Default private key +- Default password storage + +Passwords are stored in macOS Keychain, not SwiftData or UserDefaults. Current rsync execution is designed primarily around SSH keys or an SSH agent; a stored password is not placed on the command line. + +## 6. Rsync Options + +### Archive + +Archive mode (`-a`) is the normal choice for directory backup/synchronisation. It recursively copies files and preserves common metadata. + +### Compress + +Compress (`-z`) can help on slower network links. It is often unnecessary for local copies or already-compressed files such as JPEG, ZIP and video. + +### Delete Extraneous Files + +Delete mode (`--delete`) removes destination files that no longer exist at the source. + +**Use Dry Run before enabling a destructive sync.** A mistaken source or destination combined with Delete can remove destination data. + +### Preserve Permissions + +Preserves permissions when Archive mode is not being used. + +### Extended Attributes + +On modern rsync, Rsync Studio uses `-X`. With Apple `/usr/bin/rsync`, local jobs use the Apple-compatible `-E` form. For SSH to modern Linux rsync, the Apple-only extended attribute flag is omitted to avoid an incompatible remote option. + +### Exclusions + +Add patterns for files or directories you do not want transferred. + +Examples: + +`.DS_Store` + +`*.tmp` + +`node_modules/` + +`.git/` + +### Additional arguments + +Advanced rsync options can be entered as additional arguments. Use this only when you understand the rsync option and its effect. + +Example: + +`--max-size=2G` + +## 7. Dry Run + +Dry Run adds `--dry-run`. Rsync reports what it **would** change without transferring or deleting files. + +Use Dry Run when: + +- Creating a new job +- Changing source or destination paths +- Enabling Delete +- Changing exclusions +- Changing SSH destinations +- Testing a large sync + +The summary window shows files checked, files that would transfer, data size and transfer statistics. + +## 8. Run Now + +Select a job and choose **Run Now**. Manual runs display a progress/result sheet. The complete stdout/stderr output is retained in History. + +Only one rsync process is run by the current runner at a time. If another run is already active, a second job waits for the automation manager or is rejected for a direct manual launch depending on how it was triggered. + +## 9. Scheduling Jobs + +Edit a job and enable **Schedule**. + +Available schedule types include: + +- Once +- Hourly +- Daily +- Weekly +- Monthly +- Custom interval + +Scheduled runs execute without displaying the manual progress sheet. Results still appear in History. + +### Daily example + +Job: `Nightly Documents` + +Schedule: Daily + +Start time: `23:30` + +Result: the job runs each day at approximately the configured application schedule time while Rsync Studio is running. + +### Weekly example + +Job: `Weekly Photos` + +Schedule: Weekly + +Choose the desired start date/time. The application calculates and displays the next run. + +### Custom interval example + +Set a custom interval of `30` minutes to request a run every 30 minutes. + +Avoid extremely short intervals for large jobs. A new run should not repeatedly start before the previous transfer has finished. + +## 10. Calendar + +Select **Calendar** at the top of the main window. + +The calendar shows scheduled jobs by date and allows you to review upcoming automated work. Use it to confirm that a newly edited schedule produces the next run you expected. + +If a job does not appear: + +1. Confirm the job is enabled. +2. Confirm Schedule is enabled for that job. +3. Confirm the start date is valid. +4. Return to the job and check the displayed **Next scheduled run** value. + +## 11. Watch for File or Folder Changes + +Edit a job and enable **File Watching**. + +Rsync Studio uses macOS FSEvents rather than repeatedly scanning the whole directory tree. + +### Watch the job source directory + +Leave the custom watch path empty. The job source is watched automatically. + +Example source: + +`/Users/mag/Developer/MyProject/` + +When changes occur beneath that directory, the watch service debounces the events and triggers the configured sync. + +### Watch one specific file + +Set the watch path to a specific file, for example: + +`/Users/mag/Documents/config.json` + +The service watches the containing directory and filters events so only changes affecting that file trigger the job. + +### Debounce + +Applications often save a file several times in quick succession. The debounce period groups those events into one rsync run. + +Example: + +Debounce: `5 seconds` + +Saving 10 files over a three-second period results in a single sync after activity settles, rather than 10 separate rsync processes. + +### Good watch-job examples + +- Sync a development project to a test server after edits. +- Mirror a document directory to a local backup disk. +- Upload generated reports when files change. +- Synchronise a configuration directory to another Mac or Linux machine. + +### Avoid watch loops + +Do not configure the destination inside the watched source tree. Otherwise rsync's own writes can generate more filesystem events and repeatedly trigger the job. + +Bad example: + +Source: `/Users/mag/Data/` + +Destination: `/Users/mag/Data/Backup/` + +Prefer a destination outside the watched source hierarchy. + +## 12. Background Operation + +Scheduled and file-watch jobs run without opening the manual progress sheet. The application must still be running for its scheduler and FSEvents watchers to operate. + +Enable **Launch at Login** in Settings if you want Rsync Studio to start automatically after you sign in to macOS. You can then close or minimise the main window while the app continues running. + +Quitting Rsync Studio stops in-process schedules and file watching until the application starts again. + +## 13. Jobs Screen + +The Jobs screen is the main status dashboard. Each job shows its key automation and run state. + +Use the filters to review: + +- All jobs +- Scheduled jobs +- Watching jobs +- Jobs with issues + +Select a job to open its full detail view. The **Edit Job** button remains fixed at the top of the detail pane so it is available even after scrolling or on narrower windows. + +## 14. History + +Select **History** to review completed runs. + +History records whether the run was started by: + +- Manual action +- Schedule +- File change + +Select a run to inspect: + +- Start/end time +- Exit status +- rsync command +- Transfer summary +- stdout +- stderr +- Dry-run state + +Use **Export Log** from the run result/detail view when you need a text copy for troubleshooting or auditing. + +## 15. Common Rsync Exit Codes + +`0` — Success. + +`1` — Syntax or usage error. + +`2` — Protocol incompatibility. + +`3` — File selection/input-output errors. + +`10` — Socket I/O error. + +`11` — File I/O error. + +`12` — Error in rsync protocol data stream. + +`23` — Partial transfer due to error. + +`24` — Partial transfer because source files vanished during the run. + +`30` — Timeout in data send/receive. + +Always inspect stderr in History for the actual diagnostic message. + +## 16. Troubleshooting + +### Permission denied + +Check that the macOS user running Rsync Studio can read the source and write to the destination. For remote jobs, check permissions for the remote SSH user. + +### Host key verification failed + +Connect once using Terminal: + +`ssh mag@pegasus.local` + +Verify the host fingerprint and allow SSH to update `~/.ssh/known_hosts`. + +Do not disable host-key verification simply to hide this warning. + +### Public key authentication failed + +Check: + +- The private key path is correct. +- The remote user's `~/.ssh/authorized_keys` contains the matching public key. +- File permissions on the remote `.ssh` directory/key files are valid. +- The SSH username is correct. + +### Remote rsync option error + +If you are using `/usr/bin/rsync` on macOS against modern Linux, avoid manually adding Apple-specific rsync options. Installing Homebrew rsync provides better modern compatibility. + +### Nothing transfers + +Run a Dry Run and inspect the command and output. Check exclusions, trailing slash behaviour and whether source/destination already contain identical data. + +### Watch job does not trigger + +Check that: + +- The job is enabled. +- Global file watching is enabled in Settings. +- File Watching is enabled on the job. +- The watch path exists. +- Rsync Studio is still running. +- The change occurred beneath the watched path, or matches the selected single file. + +### Schedule does not run + +Check that: + +- Global scheduling is enabled in Settings. +- The job itself is enabled. +- Schedule is enabled for the job. +- The displayed next-run time is correct. +- Rsync Studio is running at the scheduled time. + +## 17. Recommended Backup Workflow + +For an important backup job: + +1. Create the job without Delete enabled. +2. Run a Dry Run. +3. Review the source and destination in the generated command. +4. Run the job manually. +5. Check History for exit code 0. +6. Verify several files at the destination. +7. Only then enable a schedule. +8. Enable Delete only when you specifically require a mirror and understand the deletion behaviour. + +Rsync synchronisation is not automatically a versioned backup. If the destination mirrors source deletions, it does not replace a separate versioned/offline backup strategy. + +## 18. Example Configurations + +### Local external-drive backup + +Name: `Documents to USB` + +Source: `/Users/mag/Documents/` + +Destination: `/Volumes/Backup/Documents/` + +Archive: On + +Delete: Off initially + +Schedule: Daily at 22:00 + +### Mac to Linux server + +Name: `Projects to Pegasus` + +Source: `/Users/mag/Developer/` + +Host: `pegasus.local` + +User: `mag` + +Port: `22` + +Private key: `/Users/mag/.ssh/id_ed25519` + +Remote destination: `/home/mag/backups/Developer/` + +Archive: On + +Compress: On + +Schedule: Daily + +### Live project watcher + +Name: `Website Live Sync` + +Source: `/Users/mag/Sites/MySite/` + +Remote destination: `/var/www/mysite/` + +Watch: On + +Watch path: leave empty to watch the source + +Debounce: 5 seconds + +Exclusions: `.git/`, `node_modules/`, `.DS_Store` + +Use this style only when automatic deployment to that destination is intentional. + +## 19. Safety Checklist + +Before running a new job, confirm: + +- Source is correct. +- Destination is correct. +- Remote host/user are correct. +- The selected SSH key is private, not `.pub`. +- Delete mode is intentional. +- Exclusions are correct. +- Dry Run output looks correct. +- The destination has enough free space. +- A watch destination is not inside its watched source. + +## 20. Getting Help Information from the App + +Select the **Help** button in the main Rsync Studio header. The bundled guide you are reading opens inside the application and is available without an Internet connection. + +For an rsync-specific option not documented here, run `man rsync` in Terminal for the version of rsync installed on your Mac. Behaviour can differ between the Apple-supplied rsync and current Homebrew rsync. + + +## Sync All + +Use **Sync All** in the Jobs sidebar to run every enabled job in sequence. Rsync Studio 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 Studio 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 Studio 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 Studio 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 Studio 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 Studio 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. + + +# Production Reliability + +## Test Job / Preflight +Use **Test Job** before relying on unattended automation. Local jobs verify configuration, rsync availability, source reachability, destination volume identity, and configured free-space reserve. SSH jobs verify batch authentication, remote rsync availability, and remote destination access. + +## External volume protection +For removable drives, enable **Require this exact destination volume** and capture the mounted destination. If that volume is absent, Rsync Studio refuses to sync to another filesystem that happens to appear at the same path. + +## Retries and notifications +Scheduled and watched jobs can retry automatically with increasing delays. Per-job notifications can report failures and, optionally, successful completion. Remote queued jobs wait while the Mac is offline and resume when network connectivity returns. + +## Sync All +Sync All runs enabled jobs sequentially, displays succeeded/failed/skipped totals, and can be cancelled from the Jobs dashboard. + +## Configuration backup +Use the Jobs menu to export or import a versioned JSON job configuration. Private SSH-key paths are intentionally omitted. Imported remote jobs are disabled until authentication is confirmed on that Mac. + +## Diagnostics +Settings → General → **Export Diagnostics…** creates a redacted support report containing application, macOS and rsync versions, job status and recent failures without exporting SSH private-key paths or credentials. + +## Updates +The built-in update checker accepts only an HTTPS manifest signed with the Ed25519 public key embedded in the signed app. Until a production feed and key are configured, update checking remains safely disabled. diff --git a/Rsync Studio/Rsync StudioApp.swift b/Rsync Studio/Rsync StudioApp.swift new file mode 100644 index 0000000..bc7cbba --- /dev/null +++ b/Rsync Studio/Rsync StudioApp.swift @@ -0,0 +1,88 @@ +import SwiftUI +import SwiftData + +@main +@MainActor +struct RsyncStudioApp: App { + let sharedModelContainer: ModelContainer + let startupStorageError: String? + @StateObject private var runner: RsyncRunner + @StateObject private var automation: BackgroundSyncCoordinator + @StateObject private var licensing: LicensingManager + + init() { + let schema = Schema([ + SyncJob.self, + SyncRun.self, + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + KeychainStore.delete("defaultSSHPassword") + _licensing = StateObject(wrappedValue: LicensingManager()) + + do { + let container = try ModelContainer(for: schema, configurations: [configuration]) + sharedModelContainer = container + startupStorageError = nil + let rsyncRunner = RsyncRunner() + _runner = StateObject(wrappedValue: rsyncRunner) + _automation = StateObject(wrappedValue: BackgroundSyncCoordinator(modelContext: container.mainContext, runner: rsyncRunner)) + } catch { + let fallbackConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + do { + let fallback = try ModelContainer(for: schema, configurations: [fallbackConfiguration]) + sharedModelContainer = fallback + startupStorageError = "Rsync Studio 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: BackgroundSyncCoordinator(modelContext: fallback.mainContext, runner: rsyncRunner)) + } catch { + fatalError("Rsync Studio could not initialise persistent or temporary storage: \(error)") + } + } + } + + var body: some Scene { + WindowGroup { + ContentView(startupStorageError: startupStorageError) + .frame(minWidth: 1040, minHeight: 680) + .environmentObject(runner) + .environmentObject(automation) + .environmentObject(licensing) + .onAppear { + licensing.refresh() + if licensing.canUseApp { automation.start() } else { automation.stop() } + } + .onChange(of: licensing.canUseApp) { _, allowed in + if allowed { automation.start() } else { automation.stop() } + } + } + .modelContainer(sharedModelContainer) + .commands { + RsyncStudioCommands() + } + + Window("Rsync Studio Help", id: "help") { + HelpView() + } + .defaultSize(width: 940, height: 780) + + Settings { + SettingsView() + .environmentObject(automation) + .environmentObject(licensing) + } + } +} + +private struct RsyncStudioCommands: Commands { + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(replacing: .help) { + Button("Rsync Studio Help") { + openWindow(id: "help") + } + .keyboardShortcut("?", modifiers: [.command]) + } + } +} diff --git a/Rsync Studio/Services/BackgroundSyncCoordinator.swift b/Rsync Studio/Services/BackgroundSyncCoordinator.swift new file mode 100644 index 0000000..0920bdf --- /dev/null +++ b/Rsync Studio/Services/BackgroundSyncCoordinator.swift @@ -0,0 +1,370 @@ +import Foundation +import SwiftData +import Network +@preconcurrency import UserNotifications + +@MainActor +final class BackgroundSyncCoordinator: ObservableObject { + enum Activity: Equatable { + case idle + case waiting(String) + case running(String) + + var title: String { + switch self { + case .idle: return "Idle" + case .waiting(let name): return "Waiting · \(name)" + case .running(let name): return "Running · \(name)" + } + } + } + + @Published private(set) var activity: Activity = .idle + @Published private(set) var watchedJobCount = 0 + @Published private(set) var scheduledJobCount = 0 + @Published private(set) var isSyncAllRunning = false + @Published private(set) var syncAllCompleted = 0 + @Published private(set) var syncAllTotal = 0 + @Published private(set) var syncAllFailed = 0 + @Published private(set) var syncAllSkipped = 0 + @Published private(set) var isNetworkAvailable = true + @Published private(set) var lastError: String? + + var activityDisplayTitle: String { + if isSyncAllRunning { + return "Sync All · \(min(syncAllCompleted + 1, syncAllTotal)) of \(syncAllTotal)" + } + return activity.title + } + + private let modelContext: ModelContext + private let runner: RsyncRunner + private let watcher = FileWatchService() + private var timer: Timer? + private var pending: [(UUID, SyncRunTrigger)] = [] + private var pendingIDs = Set() + private var isProcessingQueue = false + private var watchDebounceTasks: [UUID: Task] = [:] + private var started = false + private var notificationToken: NSObjectProtocol? + private let networkMonitor = NWPathMonitor() + private let networkQueue = DispatchQueue(label: "net.absdev.RsyncStudio.network") + private var currentTrigger: SyncRunTrigger? + + init(modelContext: ModelContext, runner: RsyncRunner) { + self.modelContext = modelContext + self.runner = runner + watcher.onChange = { [weak self] jobID, path in + Task { @MainActor [weak self] in + self?.fileChanged(jobID: jobID, path: path) + } + } + networkMonitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + self?.isNetworkAvailable = path.status == .satisfied + if path.status == .satisfied { self?.processQueue() } + } + } + networkMonitor.start(queue: networkQueue) + notificationToken = NotificationCenter.default.addObserver( + forName: .syncJobsChanged, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.refreshConfiguration() } + } + } + + deinit { + networkMonitor.cancel() + if let notificationToken { NotificationCenter.default.removeObserver(notificationToken) } + } + + func start() { + 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 + self?.refreshConfiguration() + self?.checkSchedules() + self?.processQueue() + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + watcher.stop() + watchDebounceTasks.values.forEach { $0.cancel() } + watchDebounceTasks.removeAll() + started = false + } + + func syncAllEnabledJobs() { + guard !isSyncAllRunning else { return } + + let enabledJobs = fetchJobs().filter(\.isEnabled) + syncAllCompleted = 0 + syncAllTotal = 0 + syncAllFailed = 0 + syncAllSkipped = 0 + + for job in enabledJobs { + if enqueue(jobID: job.id, trigger: .syncAll) { + syncAllTotal += 1 + } + } + + isSyncAllRunning = syncAllTotal > 0 + if !isSyncAllRunning { + syncAllCompleted = 0 + } + } + + func cancelSyncAll() { + guard isSyncAllRunning else { return } + let before = pending.count + pending.removeAll { item in + if item.1 == .syncAll { + pendingIDs.remove(item.0) + return true + } + return false + } + syncAllSkipped += max(0, before - pending.count) + syncAllTotal = max(syncAllCompleted + syncAllFailed + syncAllSkipped, syncAllTotal) + if currentTrigger == .syncAll && runner.isRunning { runner.cancel() } + if currentTrigger != .syncAll { + isSyncAllRunning = false + } + } + + func refreshConfiguration() { + let jobs = fetchJobs() + scheduledJobCount = jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }.count + watchedJobCount = jobs.filter { $0.isEnabled && $0.watchEnabled }.count + + if UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true { + watcher.update(jobs: jobs) + } else { + watcher.update(jobs: []) + } + + for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil { + job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) + } + saveContext("refreshing automation configuration") + } + + private func checkSchedules() { + guard UserDefaults.standard.object(forKey: "backgroundSchedulesEnabled") as? Bool ?? true else { return } + let now = Date() + for job in fetchJobs() where job.isEnabled && job.scheduleEnabled && job.scheduleKind != .manual { + guard let next = job.nextRunAt, next <= now else { continue } + enqueue(jobID: job.id, trigger: .scheduled) + + if job.scheduleKind == .once { + job.scheduleEnabled = false + job.nextRunAt = nil + } else { + job.nextRunAt = job.calculateNextRun(after: now) + } + } + saveContext("updating scheduled jobs") + } + + private func fileChanged(jobID: UUID, path: String) { + guard UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true else { return } + guard let job = fetchJob(id: jobID), job.isEnabled, job.watchEnabled else { return } + job.lastWatchEventAt = Date() + watchDebounceTasks[jobID]?.cancel() + let delay = max(1, job.watchDebounceSeconds) + watchDebounceTasks[jobID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + _ = self.enqueue(jobID: jobID, trigger: .watched) + } + saveContext("recording file-watch activity") + } + + @discardableResult + private func enqueue(jobID: UUID, trigger: SyncRunTrigger) -> Bool { + guard !pendingIDs.contains(jobID), runner.currentJobID != jobID else { return false } + pending.append((jobID, trigger)) + pendingIDs.insert(jobID) + processQueue() + return true + } + + private func processQueue() { + guard !isProcessingQueue, !runner.isRunning, !pending.isEmpty else { + if (isProcessingQueue || runner.isRunning), let first = pending.first, let job = fetchJob(id: first.0) { + activity = .waiting(job.name) + } + return + } + + let item = pending.removeFirst() + pendingIDs.remove(item.0) + guard let job = fetchJob(id: item.0), job.isEnabled else { + if item.1 == .syncAll { syncAllSkipped += 1 } + finishSyncAllItemIfNeeded(item.1, succeeded: false, skipped: true) + processQueue() + return + } + + if job.isRemote && !isNetworkAvailable { + pending.insert(item, at: 0) + pendingIDs.insert(item.0) + activity = .waiting("Network · \(job.name)") + return + } + + isProcessingQueue = true + currentTrigger = item.1 + activity = .running(job.name) + Task { [weak self] in + guard let self else { return } + let succeeded = await self.execute(job: job, trigger: item.1) + self.finishSyncAllItemIfNeeded(item.1, succeeded: succeeded, skipped: false) + self.isProcessingQueue = false + self.currentTrigger = nil + self.activity = .idle + self.processQueue() + } + } + + private func finishSyncAllItemIfNeeded(_ trigger: SyncRunTrigger, succeeded: Bool, skipped: Bool) { + guard trigger == .syncAll else { return } + if skipped { syncAllSkipped += 0 } + else if succeeded { syncAllCompleted += 1 } + else { syncAllFailed += 1 } + if syncAllCompleted + syncAllFailed + syncAllSkipped >= syncAllTotal { + isSyncAllRunning = false + sendNotification(title: "Sync All complete", body: "\(syncAllCompleted) succeeded · \(syncAllFailed) failed · \(syncAllSkipped) skipped") + } + } + + private func execute(job: SyncJob, trigger: SyncRunTrigger) async -> Bool { + let executable = RsyncLocator.locate() + let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable) + let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger) + modelContext.insert(run) + saveContext("creating run history") + + let automatic = trigger == .scheduled || trigger == .watched + let maxAttempts = automatic ? max(1, job.retryCount + 1) : 1 + var finalSuccess = false + var combinedOutput = "" + var combinedError = "" + + for attempt in 1...maxAttempts { + if attempt > 1 { + let delay = max(1, job.retryDelaySeconds) * (attempt - 1) + combinedError += "\nRetry \(attempt - 1) of \(maxAttempts - 1) after \(delay)s…\n" + try? await Task.sleep(for: .seconds(delay)) + } + do { + let preflight = await runner.preflight(job: job) + guard preflight.passed else { + combinedError += preflight.checks.filter { $0.severity == .error }.map(\.detail).joined(separator: "\n") + break + } + let result = try await runner.run(job: job, dryRun: false, trigger: trigger) + combinedOutput += result.stdout + combinedError += result.stderr + run.exitCode = Int(result.exitCode) + run.wasCancelled = result.wasCancelled + if result.exitCode == 0 { + finalSuccess = true + break + } + if result.wasCancelled { break } + } catch { + combinedError += "\n\(error.localizedDescription)\n" + run.exitCode = -1 + } + } + + run.output = SyncRun.sanitizedLog(combinedOutput) + run.errorOutput = SyncRun.sanitizedLog(combinedError) + run.finishedAt = Date() + job.lastRunAt = run.finishedAt + job.lastExitCode = run.exitCode + job.updatedAt = Date() + saveContext("saving run history") + pruneHistoryIfNeeded() + + if finalSuccess, job.notifyOnSuccess { sendNotification(title: "Sync complete", body: job.name) } + if !finalSuccess, job.notifyOnFailure { sendNotification(title: "Sync failed", body: job.name) } + return finalSuccess + } + + private func sendNotification(title: String, body: String) { + Task { @MainActor in + do { + let center = UNUserNotificationCenter.current() + let granted = try await center.requestAuthorization(options: [.alert, .sound]) + guard granted else { return } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + try await center.add(request) + } catch { + lastError = "Could not deliver notification: \(error.localizedDescription)" + } + } + } + + 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()) + 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] { + (try? modelContext.fetch(FetchDescriptor(sortBy: [SortDescriptor(\SyncJob.name)]))) ?? [] + } + + private func fetchJob(id: UUID) -> SyncJob? { + fetchJobs().first { $0.id == id } + } +} + +extension Notification.Name { + static let syncJobsChanged = Notification.Name("RsyncStudio.syncJobsChanged") +} diff --git a/Rsync Studio/Services/FileWatchService.swift b/Rsync Studio/Services/FileWatchService.swift new file mode 100644 index 0000000..99010b1 --- /dev/null +++ b/Rsync Studio/Services/FileWatchService.swift @@ -0,0 +1,94 @@ +import Foundation +import CoreServices + +final class FileWatchService { + typealias ChangeHandler = @Sendable (UUID, String) -> Void + + private struct WatchTarget: Equatable { + let targetPath: String + let streamRoot: String + let isDirectory: Bool + } + + private var stream: FSEventStreamRef? + private var watchedTargets: [UUID: WatchTarget] = [:] + private let queue = DispatchQueue(label: "net.absdev.RsyncStudio.fsevents", qos: .utility) + var onChange: ChangeHandler? + + deinit { stop() } + + func update(jobs: [SyncJob]) { + var targets: [UUID: WatchTarget] = [:] + for job in jobs where job.isEnabled && job.watchEnabled { + let path = (job.effectiveWatchPath as NSString).expandingTildeInPath + guard !path.isEmpty else { continue } + + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + let directory = exists ? isDirectory.boolValue : true + let streamRoot = directory ? path : (path as NSString).deletingLastPathComponent + targets[job.id] = WatchTarget(targetPath: path, streamRoot: streamRoot, isDirectory: directory) + } + guard targets != watchedTargets else { return } + watchedTargets = targets + rebuild() + } + + func stop() { + if let stream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } + } + + private func rebuild() { + stop() + let paths = Array(Set(watchedTargets.values.map(\.streamRoot))) + guard !paths.isEmpty else { return } + + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + let callback: FSEventStreamCallback = { _, info, count, eventPaths, _, _ in + guard let info else { return } + let service = Unmanaged.fromOpaque(info).takeUnretainedValue() + let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? [] + for index in 0.. [String] { + var args: [String] = [] + + if job.archive { + args.append("-a") + } else if job.preservePermissions { + args.append("-p") + } + + if job.compress { args.append("-z") } + + if job.preserveExtendedAttributes { + if isAppleRsync(executable) { + if !job.isRemote { args.append("-E") } + } else { + args.append("-X") + } + } + + if job.deleteExtraneous { args.append("--delete") } + if job.showProgress { args.append("--progress") } + if dryRun { args.append("--dry-run") } + + args += ["--human-readable", "--stats"] + + for exclusion in job.exclusions { + args.append("--exclude=\(exclusion)") + } + + if !job.additionalArguments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += shellSplit(job.additionalArguments) + } + + if job.isRemote { + 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 += ["-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 + } + + static func displayCommand(job: SyncJob, dryRun: Bool, executable: String) -> String { + ([executable] + build(job: job, dryRun: dryRun, executable: executable)) + .map(shellEscape) + .joined(separator: " ") + } + + static func normalizedPrivateKeyPath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath } + + let privatePath = String(trimmed.dropLast(4)) + let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath + if FileManager.default.fileExists(atPath: expandedPrivatePath) { + return expandedPrivatePath + } + return (trimmed as NSString).expandingTildeInPath + } + + private static func isAppleRsync(_ executable: String) -> Bool { + executable == "/usr/bin/rsync" + } + + private static func normalizeSource(_ path: String) -> String { path } + + static func shellEscape(_ value: String) -> String { + if value.isEmpty { return "''" } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./:@%+=,")) + if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { return value } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func shellSplit(_ string: String) -> [String] { + var result: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + for ch in string { + if escaping { + current.append(ch) + escaping = false + continue + } + if ch == "\\" { + escaping = true + continue + } + if let q = quote { + if ch == q { quote = nil } else { current.append(ch) } + continue + } + if ch == "\"" || ch == "'" { + quote = ch + } else if ch.isWhitespace { + if !current.isEmpty { + result.append(current) + current = "" + } + } else { + current.append(ch) + } + } + if !current.isEmpty { result.append(current) } + 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 let volume = SyncVolumeInspector.info(forPath: destination) { + if job.requireExpectedVolume && !job.expectedVolumeIdentifier.isEmpty && volume.identifier != job.expectedVolumeIdentifier { + return "The configured destination volume is not mounted. Rsync Studio will not use a different volume at the same path." + } + if job.minimumFreeSpaceMB > 0 && volume.freeBytes < Int64(job.minimumFreeSpaceMB) * 1_048_576 { + return "The destination does not have the configured minimum free space of \(job.minimumFreeSpaceMB) MB." + } + } else { + return "The destination volume cannot be identified." + } + } + + 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 = [ + "-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 Studio. 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 + } +} diff --git a/Rsync Studio/Services/RsyncLocator.swift b/Rsync Studio/Services/RsyncLocator.swift new file mode 100644 index 0000000..3c5d4a9 --- /dev/null +++ b/Rsync Studio/Services/RsyncLocator.swift @@ -0,0 +1,29 @@ +import Foundation + +enum RsyncLocator { + static let candidates = [ + "/opt/homebrew/bin/rsync", + "/usr/local/bin/rsync", + "/usr/bin/rsync" + ] + + static func locate() -> String { + 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 + } + } +} diff --git a/Rsync Studio/Services/RsyncRunner.swift b/Rsync Studio/Services/RsyncRunner.swift new file mode 100644 index 0000000..7b6d0cb --- /dev/null +++ b/Rsync Studio/Services/RsyncRunner.swift @@ -0,0 +1,260 @@ +import Foundation + + +@MainActor +final class RsyncRunner: ObservableObject { + @Published private(set) var isRunning = false + @Published private(set) var currentJobID: UUID? + + private var process: Process? + + struct Result { + let exitCode: Int32 + let stdout: String + let stderr: String + let wasCancelled: Bool + } + + func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) async throws -> Result { + guard !isRunning else { throw RunnerError.alreadyRunning } + 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 + currentJobID = job.id + let activityToken = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .idleSystemSleepDisabled], + reason: "Rsync Studio is synchronising \(job.name)" + ) + + defer { + ProcessInfo.processInfo.endActivity(activityToken) + isRunning = false + currentJobID = nil + process = nil + } + + let process = Process() + self.process = process + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // Keep rsync's potentially very chatty output off the MainActor. Updating a + // SwiftUI String for every rsync progress chunk makes the whole window jerk. + // These locked buffers collect every byte for history/export without causing + // any UI invalidation while the transfer is running. + let stdoutBuffer = LockedDataBuffer() + let stderrBuffer = LockedDataBuffer() + + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + stdoutBuffer.append(data) + } + + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + stderrBuffer.append(data) + } + + return try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { proc in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + + // Consume anything left between the final readability callback and EOF. + let remainingOut = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let remainingErr = stderrPipe.fileHandleForReading.readDataToEndOfFile() + if !remainingOut.isEmpty { stdoutBuffer.append(remainingOut) } + if !remainingErr.isEmpty { stderrBuffer.append(remainingErr) } + + continuation.resume(returning: Result( + exitCode: proc.terminationStatus, + stdout: stdoutBuffer.stringValue, + stderr: stderrBuffer.stringValue, + wasCancelled: proc.terminationReason == .uncaughtSignal + )) + } + + do { + try process.run() + } catch { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + continuation.resume(throwing: error) + } + } + } + + private func remoteDestinationLooksLikeHost(_ job: SyncJob) -> Bool { + let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) + return destination == host || destination == "\(job.remoteUser)@\(host)" + } + + func preflight(job: SyncJob) async -> JobPreflightReport { + var checks: [JobPreflightCheck] = [] + + if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: .manual) { + checks.append(.init(title: "Configuration", detail: message, severity: .error)) + return JobPreflightReport(checks: checks) + } + checks.append(.init(title: "Configuration", detail: "Security and argument validation passed.", severity: .success)) + + let executable = RsyncLocator.locate() + if RsyncLocator.isTrustedExecutableCandidate(executable) { + checks.append(.init(title: "Rsync", detail: executable, severity: .success)) + } else { + checks.append(.init(title: "Rsync", detail: "No trusted rsync executable is available.", severity: .error)) + } + + if job.isRemote { + let ssh = "/usr/bin/ssh" + guard FileManager.default.isExecutableFile(atPath: ssh) else { + checks.append(.init(title: "SSH", detail: "The system SSH client is unavailable.", severity: .error)) + return JobPreflightReport(checks: checks) + } + let userHost = job.remoteUser.isEmpty ? job.remoteHost : "\(job.remoteUser)@\(job.remoteHost)" + var args = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=1", "-p", String(job.remotePort)] + let key = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) + if !key.isEmpty { args += ["-o", "IdentitiesOnly=yes", "-i", key] } + let remotePath = RsyncCommandBuilder.shellEscape(job.destinationPath) + args += [userHost, "command -v rsync >/dev/null 2>&1 && test -d \(remotePath)"] + let result = await Self.runProbe(executable: ssh, arguments: args) + if result == 0 { + checks.append(.init(title: "Remote connection", detail: "SSH authentication, remote rsync and destination directory are available.", severity: .success)) + } else { + checks.append(.init(title: "Remote connection", detail: "Could not authenticate, find remote rsync, or access the destination directory.", severity: .error)) + } + } else { + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: (job.sourcePath as NSString).expandingTildeInPath, isDirectory: &isDir) { + checks.append(.init(title: "Source", detail: "Source exists and is reachable.", severity: .success)) + } else { + checks.append(.init(title: "Source", detail: "Source does not exist or is not reachable.", severity: .error)) + } + + let destination = (job.destinationPath as NSString).expandingTildeInPath + let volume = SyncVolumeInspector.info(forPath: destination) + if let volume { + checks.append(.init(title: "Destination volume", detail: "\(volume.name) · \(volume.freeSpaceDescription) free", severity: volume.freeBytes >= Int64(job.minimumFreeSpaceMB) * 1_048_576 ? .success : .error)) + if job.requireExpectedVolume && !job.expectedVolumeIdentifier.isEmpty && volume.identifier != job.expectedVolumeIdentifier { + checks.append(.init(title: "Volume identity", detail: "Expected \(job.expectedVolumeIdentifier), found \(volume.identifier).", severity: .error)) + } else if job.requireExpectedVolume { + checks.append(.init(title: "Volume identity", detail: "Expected destination volume is mounted.", severity: .success)) + } + } else { + checks.append(.init(title: "Destination volume", detail: "Could not identify the destination volume.", severity: .error)) + } + } + return JobPreflightReport(checks: checks) + } + + private static func runProbe(executable: String, arguments: [String]) async -> Int32 { + await withCheckedContinuation { continuation in + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) } + do { try process.run() } catch { continuation.resume(returning: -1) } + } + } + + func cancel() { + guard let process, process.isRunning else { return } + process.interrupt() + } + + enum RunnerError: LocalizedError { + case alreadyRunning + case securityValidation(String) + case invalidExecutable(String) + + var errorDescription: String? { + switch self { + 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." + } + } + } +} + +private final class LockedDataBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ newData: Data) { + lock.lock() + data.append(newData) + lock.unlock() + } + + var stringValue: String { + lock.lock() + let snapshot = data + lock.unlock() + return String(decoding: snapshot, as: UTF8.self) + } +} + + +enum JobPreflightSeverity: Int, Comparable { + case success = 0, warning = 1, error = 2 + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } +} + +struct JobPreflightCheck: Identifiable { + let id = UUID() + let title: String + let detail: String + let severity: JobPreflightSeverity +} + +struct JobPreflightReport { + let checks: [JobPreflightCheck] + var severity: JobPreflightSeverity { checks.map(\.severity).max() ?? .success } + var passed: Bool { severity != .error } + var title: String { passed ? (severity == .warning ? "Ready with warnings" : "Ready") : "Needs attention" } +} + +struct SyncVolumeInfo { + let identifier: String + let name: String + let freeBytes: Int64 + var freeSpaceDescription: String { ByteCountFormatter.string(fromByteCount: freeBytes, countStyle: .file) } +} + +enum SyncVolumeInspector { + static func info(forPath path: String) -> SyncVolumeInfo? { + var url = URL(fileURLWithPath: path) + while !FileManager.default.fileExists(atPath: url.path), url.path != "/" { url.deleteLastPathComponent() } + do { + let values = try url.resourceValues(forKeys: [.volumeIdentifierKey, .volumeNameKey, .volumeAvailableCapacityForImportantUsageKey]) + guard let rawID = values.volumeIdentifier else { return nil } + return SyncVolumeInfo( + identifier: String(describing: rawID), + name: values.volumeName ?? url.path, + freeBytes: values.volumeAvailableCapacityForImportantUsage ?? 0 + ) + } catch { return nil } + } +} diff --git a/Rsync Studio/SettingsView.swift b/Rsync Studio/SettingsView.swift new file mode 100644 index 0000000..3e7985e --- /dev/null +++ b/Rsync Studio/SettingsView.swift @@ -0,0 +1,321 @@ +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 ? "" : 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)") + } + } +} diff --git a/Rsync Studio/Utilities/FilePicker.swift b/Rsync Studio/Utilities/FilePicker.swift new file mode 100644 index 0000000..4d19593 --- /dev/null +++ b/Rsync Studio/Utilities/FilePicker.swift @@ -0,0 +1,21 @@ +import AppKit + +@MainActor +enum FilePicker { + static func chooseDirectory() -> String? { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + return panel.runModal() == .OK ? panel.url?.path : nil + } + + static func chooseFile() -> String? { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + return panel.runModal() == .OK ? panel.url?.path : nil + } +} diff --git a/Rsync Studio/Utilities/KeychainStore.swift b/Rsync Studio/Utilities/KeychainStore.swift new file mode 100644 index 0000000..c6f62a5 --- /dev/null +++ b/Rsync Studio/Utilities/KeychainStore.swift @@ -0,0 +1,55 @@ +import Foundation +import Security + +enum KeychainStore { + private static let service = "net.absdev.RsyncStudio" + + static func string(for account: String) -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { return "" } + return value + } + + @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 { return delete(account) } + + let data = Data(value.utf8) + 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 + } +} diff --git a/Rsync Studio/Views/Components/FlowLayout.swift b/Rsync Studio/Views/Components/FlowLayout.swift new file mode 100644 index 0000000..cc6d259 --- /dev/null +++ b/Rsync Studio/Views/Components/FlowLayout.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? .infinity + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x + size.width > width, x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: width.isFinite ? width : x, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX, x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/Rsync Studio/Views/Components/PathField.swift b/Rsync Studio/Views/Components/PathField.swift new file mode 100644 index 0000000..f39100b --- /dev/null +++ b/Rsync Studio/Views/Components/PathField.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct PathField: View { + let title: String + @Binding var path: String + var canBrowse: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.headline) + HStack { + TextField(title, text: $path) + .textFieldStyle(.roundedBorder) + if canBrowse { + Button("Choose…") { + if let selected = FilePicker.chooseDirectory() { path = selected } + } + } + } + } + } +} diff --git a/Rsync Studio/Views/Components/SplashView.swift b/Rsync Studio/Views/Components/SplashView.swift new file mode 100644 index 0000000..dd796f3 --- /dev/null +++ b/Rsync Studio/Views/Components/SplashView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct SplashView: View { + @State private var animate = false + + var body: some View { + ZStack { + LinearGradient( + colors: [Color.accentColor.opacity(0.95), Color.accentColor.opacity(0.62), Color(nsColor: .windowBackgroundColor)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 22) { + ZStack { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(.ultraThinMaterial) + .frame(width: 126, height: 126) + .shadow(color: .black.opacity(0.18), radius: 28, y: 14) + + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 58, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white) + .rotationEffect(.degrees(animate ? 360 : 0)) + } + + VStack(spacing: 6) { + Text("Rsync Studio") + .font(.system(size: 32, weight: .bold, design: .rounded)) + Text("Fast, dependable file synchronisation for macOS") + .font(.headline) + .foregroundStyle(.secondary) + } + + ProgressView() + .controlSize(.small) + .padding(.top, 2) + } + .padding(48) + } + .onAppear { + withAnimation(.linear(duration: 1.25).repeatForever(autoreverses: false)) { + animate = true + } + } + } +} diff --git a/Rsync Studio/Views/Components/StatusBadge.swift b/Rsync Studio/Views/Components/StatusBadge.swift new file mode 100644 index 0000000..bd5171e --- /dev/null +++ b/Rsync Studio/Views/Components/StatusBadge.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct StatusBadge: View { + let run: SyncRun + + var body: some View { + Label(text, systemImage: icon) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + } + + private var text: String { + if run.isRunning { return "Running" } + if run.wasCancelled { return "Cancelled" } + return run.succeeded ? "Success" : "Failed" + } + + private var icon: String { + if run.isRunning { return "arrow.triangle.2.circlepath" } + if run.wasCancelled { return "stop.circle" } + return run.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill" + } +} diff --git a/Rsync Studio/Views/HelpView.swift b/Rsync Studio/Views/HelpView.swift new file mode 100644 index 0000000..533986b --- /dev/null +++ b/Rsync Studio/Views/HelpView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +struct HelpView: View { + @Environment(\.dismiss) private var dismiss + @State private var blocks: [HelpBlock] = [.paragraph("Loading help…")] + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "questionmark.circle.fill") + .font(.title2) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 1) { + Text("Rsync Studio Help") + .font(.headline) + Text("Instructions, examples and troubleshooting") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Done") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.bar) + + Divider() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + .textSelection(.enabled) + .frame(maxWidth: 900, alignment: .leading) + .padding(.horizontal, 34) + .padding(.vertical, 28) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .frame(minWidth: 780, idealWidth: 940, minHeight: 640, idealHeight: 780) + .task { loadHelp() } + } + + @ViewBuilder + private func blockView(_ block: HelpBlock) -> some View { + switch block { + case .heading(let level, let text): + Text(text) + .font(headingFont(level)) + .padding(.top, level == 1 ? 4 : 10) + .frame(maxWidth: .infinity, alignment: .leading) + + case .paragraph(let text): + Text(markdown(text)) + .font(.body) + .lineSpacing(4) + .frame(maxWidth: .infinity, alignment: .leading) + + case .bullet(let text): + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("•") + .font(.body.bold()) + Text(markdown(text)) + .font(.body) + .lineSpacing(3) + } + .padding(.leading, 6) + + case .numbered(let number, let text): + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("\(number).") + .font(.body.weight(.semibold)) + .frame(width: 28, alignment: .trailing) + Text(markdown(text)) + .font(.body) + .lineSpacing(3) + } + + case .code(let text): + ScrollView(.horizontal) { + Text(text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + .background(Color(nsColor: .textBackgroundColor).opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(.quaternary, lineWidth: 1) + } + } + } + + private func headingFont(_ level: Int) -> Font { + switch level { + case 1: return .largeTitle.bold() + case 2: return .title2.bold() + case 3: return .title3.weight(.semibold) + default: return .headline + } + } + + private func markdown(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(text) + } + + private func loadHelp() { + guard let url = Bundle.main.url(forResource: "Rsync Studio Help", withExtension: "md"), + let text = try? String(contentsOf: url, encoding: .utf8) else { + blocks = [.heading(1, "Help unavailable"), .paragraph("The bundled help document could not be loaded.")] + return + } + blocks = HelpMarkdownParser.parse(text) + } +} + +private enum HelpBlock { + case heading(Int, String) + case paragraph(String) + case bullet(String) + case numbered(Int, String) + case code(String) +} + +private enum HelpMarkdownParser { + static func parse(_ source: String) -> [HelpBlock] { + var result: [HelpBlock] = [] + var paragraph: [String] = [] + var codeLines: [String] = [] + var insideCode = false + + func flushParagraph() { + guard !paragraph.isEmpty else { return } + result.append(.paragraph(paragraph.joined(separator: " "))) + paragraph.removeAll(keepingCapacity: true) + } + + func flushCode() { + guard !codeLines.isEmpty else { return } + result.append(.code(codeLines.joined(separator: "\n"))) + codeLines.removeAll(keepingCapacity: true) + } + + for rawLine in source.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + + if line.hasPrefix("```") { + if insideCode { + flushCode() + insideCode = false + } else { + flushParagraph() + insideCode = true + } + continue + } + + if insideCode { + codeLines.append(rawLine) + continue + } + + if line.isEmpty { + flushParagraph() + continue + } + + if let heading = heading(from: line) { + flushParagraph() + result.append(.heading(heading.level, heading.text)) + continue + } + + if line.hasPrefix("- ") || line.hasPrefix("* ") { + flushParagraph() + result.append(.bullet(String(line.dropFirst(2)))) + continue + } + + if let numbered = numberedItem(from: line) { + flushParagraph() + result.append(.numbered(numbered.number, numbered.text)) + continue + } + + paragraph.append(line) + } + + flushParagraph() + flushCode() + return result + } + + private static func heading(from line: String) -> (level: Int, text: String)? { + let hashes = line.prefix { $0 == "#" }.count + guard hashes > 0, hashes <= 6 else { return nil } + let remainder = line.dropFirst(hashes) + guard remainder.first == " " else { return nil } + return (hashes, String(remainder.dropFirst())) + } + + private static func numberedItem(from line: String) -> (number: Int, text: String)? { + guard let dot = line.firstIndex(of: ".") else { return nil } + let numberText = line[.., + buttonTitle: String, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(colour.opacity(0.12)) + Image(systemName: icon) + .foregroundStyle(colour) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.body.weight(.semibold)) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 12) + + Toggle("", isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + + Button(buttonTitle, action: action) + .buttonStyle(.bordered) + .controlSize(.small) + .frame(width: 88) + } + .padding(.vertical, 10) + } + + private var watchSubtitle: String { + guard job.watchEnabled else { return "Off" } + let path = job.effectiveWatchPath + return "Watching \(path) · \(job.watchDebounceSeconds)s debounce" + } + + private var optionsSection: some View { + GroupBox("Options") { + FlowLayout(spacing: 8) { + option("Archive", job.archive) + option("Compress", job.compress) + option("Delete", job.deleteExtraneous) + option("Permissions", job.preservePermissions) + option("Extended Attributes", job.preserveExtendedAttributes) + option("Progress", job.showProgress) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 3) + } + } + + private var scheduleBinding: Binding { + 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 } + if job.scheduleStartAt < Date() { job.scheduleStartAt = Date().addingTimeInterval(3600) } + job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) + } else { + job.nextRunAt = nil + } + persistAutomationChange() + } + ) + } + + private var watchBinding: Binding { + 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 + } + persistAutomationChange() + } + ) + } + + private func openEditor(_ section: JobEditorSection?) { + editorSection = section + showingEditor = true + } + + private func persistAutomationChange() { + job.updatedAt = Date() + do { + try modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + } catch { + errorMessage = "Could not save automation settings: \(error.localizedDescription)" + } + } + + @ViewBuilder + private func option(_ name: String, _ enabled: Bool) -> some View { + if enabled { + Label(name, systemImage: "checkmark") + .font(.caption.weight(.medium)) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(.quaternary, in: Capsule()) + } + } + + private enum RunPresentation { + case manual + case scheduled + + var showsModal: Bool { + switch self { + case .manual: return true + case .scheduled: return false + } + } + } + + private func launch(dryRun: Bool, presentation: RunPresentation) { + let executable = RsyncLocator.locate() + let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable) + let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual) + modelContext.insert(run) + do { + try modelContext.save() + } catch { + errorMessage = "Could not create run history: \(error.localizedDescription)" + return + } + + if presentation.showsModal { + presentedRun = run + showingRunSheet = true + } + + Task { + do { + 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() + do { try modelContext.save() } + catch { errorMessage = "Could not save run history: \(error.localizedDescription)" } + } catch { + run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription) + run.exitCode = -1 + run.finishedAt = Date() + job.lastRunAt = run.finishedAt + job.lastExitCode = -1 + do { try modelContext.save() } + catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" } + + if !presentation.showsModal { + errorMessage = error.localizedDescription + } + } + } + } +} +private struct RunProgressSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var runner: RsyncRunner + @Bindable var run: SyncRun + let job: SyncJob + @State private var exportError: String? + @State private var showTechnicalDetails = false + @State private var now = Date() + + private var isRunning: Bool { run.finishedAt == nil } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + Group { + if isRunning { runningContent } else { completedContent } + } + } + .frame(width: 680) + .frame(minHeight: 520) + .background(Color(nsColor: .windowBackgroundColor)) + .interactiveDismissDisabled(isRunning) + .task { + while isRunning { + try? await Task.sleep(for: .seconds(1)) + now = Date() + } + } + .alert("Could Not Export Log", isPresented: Binding( + get: { exportError != nil }, + set: { if !$0 { exportError = nil } } + )) { + Button("OK", role: .cancel) { exportError = nil } + } message: { + Text(exportError ?? "Unknown error") + } + } + + private var header: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + .frame(width: 44, height: 44) + Image(systemName: run.wasDryRun ? "doc.text.magnifyingglass" : "arrow.triangle.2.circlepath") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + VStack(alignment: .leading, spacing: 3) { + Text(run.wasDryRun ? "Dry Run" : "Synchronisation") + .font(.title2.bold()) + Text(run.jobName) + .foregroundStyle(.secondary) + } + Spacer() + if !isRunning { + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + } + .padding(22) + .background(.bar) + } + + private var runningContent: some View { + VStack(spacing: 26) { + Spacer(minLength: 26) + + AnimatedSyncIndicator(size: 124, lineWidth: 9) + .padding(.bottom, 2) + + VStack(spacing: 8) { + Text(run.wasDryRun ? "Checking what would change…" : "Synchronising files…") + .font(.title3.bold()) + Text(run.wasDryRun + ? "Rsync is comparing the source and destination. No files will be changed." + : "Rsync is scanning and transferring changed files. Full output is being captured without slowing the interface.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 500) + } + + connectionCard + + HStack(spacing: 22) { + Label("Elapsed \(elapsedText)", systemImage: "timer") + Label("Log captured", systemImage: "doc.text") + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + + Spacer() + + HStack { + Text("You can continue using the app while this transfer runs.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if runner.currentJobID == run.jobID { + Button("Cancel", role: .destructive) { runner.cancel() } + .buttonStyle(.bordered) + .controlSize(.large) + } + } + .padding(22) + .background(.bar) + } + } + + private var connectionCard: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Label("Source", systemImage: "folder.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(job.sourcePath) + .font(.system(.body, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "arrow.right") + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 5) { + Label("Destination", systemImage: job.isRemote ? "server.rack" : "externaldrive.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(job.destinationDisplay) + .font(.system(.body, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 28) + } + + private var completedContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + completionHero + + if run.summary.hasStatistics { + HStack(spacing: 12) { + metricCard(title: run.wasDryRun ? "Would transfer" : "Transferred", value: run.summary.numberOfFilesTransferred ?? "—", icon: "doc.on.doc.fill") + metricCard(title: "Data", value: run.summary.totalTransferredFileSize ?? "—", icon: "arrow.down.circle.fill") + metricCard(title: "Files checked", value: run.summary.numberOfFiles ?? "—", icon: "checklist") + } + + GroupBox("Transfer Summary") { + Grid(alignment: .leading, horizontalSpacing: 34, verticalSpacing: 10) { + summaryRow("Files checked", run.summary.numberOfFiles) + summaryRow(run.wasDryRun ? "Files that would transfer" : "Files transferred", run.summary.numberOfFilesTransferred) + summaryRow("Total source size", run.summary.totalFileSize) + summaryRow(run.wasDryRun ? "Data that would transfer" : "Data transferred", run.summary.totalTransferredFileSize) + summaryRow("Unmatched data", run.summary.unmatchedData) + summaryRow("Matched data", run.summary.matchedData) + summaryRow("File list size", run.summary.fileListSize) + summaryRow("List generation", run.summary.fileListGenerationTime) + summaryRow("List transfer", run.summary.fileListTransferTime) + summaryRow("Sent", run.summary.totalSent) + summaryRow("Received", run.summary.totalReceived) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if run.summary.transferRate != nil || run.summary.totalSizeAndSpeedup != nil { + VStack(alignment: .leading, spacing: 6) { + if let transferRate = run.summary.transferRate { Text(transferRate) } + if let speedup = run.summary.totalSizeAndSpeedup { Text(speedup) } + } + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + if !run.succeeded { + DisclosureGroup("Technical Details", isExpanded: $showTechnicalDetails) { + Text(run.errorOutput.isEmpty ? "Rsync exited without additional error output." : run.errorOutput) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + } + .padding(14) + .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } + + Divider() + + HStack { + Button { exportLog() } label: { + Label("Export Log…", systemImage: "square.and.arrow.up") + } + .controlSize(.large) + + Spacer() + + if let duration = run.duration { + Label(Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)), systemImage: "timer") + .foregroundStyle(.secondary) + } + + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + } + .padding(24) + } + } + + private var completionHero: some View { + HStack(spacing: 16) { + Image(systemName: completionIcon) + .font(.system(size: 42, weight: .semibold)) + .foregroundStyle(completionColour) + VStack(alignment: .leading, spacing: 4) { + Text(completionTitle) + .font(.title2.bold()) + Text(completionMessage) + .foregroundStyle(.secondary) + if let exitCode = run.exitCode, !run.succeeded { + Text("Rsync exit code \(exitCode)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(completionColour.opacity(0.09), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func metricCard(title: String, value: String, icon: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + Text(value) + .font(.title3.bold()) + .lineLimit(1) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + + private var completionIcon: String { + if run.wasCancelled { return "xmark.circle.fill" } + return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + } + + private var completionColour: Color { + if run.wasCancelled { return .secondary } + return run.succeeded ? .green : .orange + } + + private var completionTitle: String { + if run.wasCancelled { return "Synchronisation Cancelled" } + if run.succeeded { return run.wasDryRun ? "Dry Run Complete" : "Synchronisation Complete" } + return "Synchronisation Failed" + } + + private var completionMessage: String { + if run.wasCancelled { return "The transfer was stopped before it completed." } + if run.succeeded && run.wasDryRun { return "No files were changed. The summary below shows what a real run would transfer." } + if run.succeeded { return "Your files were synchronised successfully." } + if !run.errorOutput.isEmpty { + return run.errorOutput.components(separatedBy: .newlines).first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) ?? "Rsync reported an error." + } + return "Rsync could not complete this synchronisation." + } + + private var elapsedText: String { + let interval = max(0, now.timeIntervalSince(run.startedAt)) + return Duration.seconds(interval).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)) + } + + @ViewBuilder + private func summaryRow(_ title: String, _ value: String?) -> some View { + if let value { + GridRow { + Text(title).foregroundStyle(.secondary) + Text(value).fontWeight(.medium).textSelection(.enabled) + } + } + } + + private func exportLog() { + let panel = NSSavePanel() + panel.title = "Export Rsync Log" + panel.prompt = "Export" + panel.canCreateDirectories = true + panel.nameFieldStringValue = defaultLogFilename + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try run.exportText.write(to: url, atomically: true, encoding: .utf8) } + catch { exportError = error.localizedDescription } + } + + private var defaultLogFilename: String { + let safeName = run.jobName.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + return "\(safeName)-\(formatter.string(from: run.startedAt)).log" + } +} diff --git a/Rsync Studio/Views/Jobs/JobEditorView.swift b/Rsync Studio/Views/Jobs/JobEditorView.swift new file mode 100644 index 0000000..095849d --- /dev/null +++ b/Rsync Studio/Views/Jobs/JobEditorView.swift @@ -0,0 +1,362 @@ +import SwiftUI +import SwiftData + +enum JobEditorSection: Hashable { + case schedule + case fileWatch +} + +struct JobEditorView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName() + + let existingJob: SyncJob? + let initialSection: JobEditorSection? + + @State private var name: String + @State private var sourcePath: String + @State private var destinationPath: String + @State private var isRemote: Bool + @State private var remoteHost: String + @State private var remoteUser: String + @State private var remotePort: Int + @State private var sshKeyPath: String + @State private var archive: Bool + @State private var compress: Bool + @State private var deleteExtraneous: Bool + @State private var preservePermissions: Bool + @State private var preserveExtendedAttributes: Bool + @State private var showProgress: Bool + @State private var exclusionsText: String + @State private var additionalArguments: String + @State private var isEnabled: Bool + @State private var scheduleEnabled: Bool + @State private var scheduleKind: SyncScheduleKind + @State private var scheduleStartAt: Date + @State private var scheduleIntervalMinutes: Int + @State private var watchEnabled: Bool + @State private var watchPath: String + @State private var watchDebounceSeconds: Int + @State private var allowUnattendedDelete: Bool + @State private var retryCount: Int + @State private var retryDelaySeconds: Int + @State private var notifyOnFailure: Bool + @State private var notifyOnSuccess: Bool + @State private var requireExpectedVolume: Bool + @State private var expectedVolumeIdentifier: String + @State private var minimumFreeSpaceMB: Int + @State private var validationMessage: String? + + init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) { + self.existingJob = job + self.initialSection = initialSection + _name = State(initialValue: job?.name ?? "New Sync Job") + _sourcePath = State(initialValue: job?.sourcePath ?? "") + _destinationPath = State(initialValue: job?.destinationPath ?? "") + _isRemote = State(initialValue: job?.isRemote ?? false) + _remoteHost = State(initialValue: job?.remoteHost ?? UserDefaults.standard.string(forKey: "defaultRemoteHost") ?? "") + _remoteUser = State(initialValue: job?.remoteUser ?? UserDefaults.standard.string(forKey: "defaultSSHUsername") ?? NSUserName()) + let savedPort = UserDefaults.standard.integer(forKey: "defaultSSHPort") + _remotePort = State(initialValue: job?.remotePort ?? (savedPort == 0 ? 22 : savedPort)) + _sshKeyPath = State(initialValue: job?.sshKeyPath ?? UserDefaults.standard.string(forKey: "defaultSSHKeyPath") ?? "") + _archive = State(initialValue: job?.archive ?? true) + _compress = State(initialValue: job?.compress ?? false) + _deleteExtraneous = State(initialValue: job?.deleteExtraneous ?? false) + _preservePermissions = State(initialValue: job?.preservePermissions ?? true) + _preserveExtendedAttributes = State(initialValue: job?.preserveExtendedAttributes ?? true) + _showProgress = State(initialValue: job?.showProgress ?? true) + _exclusionsText = State(initialValue: job?.exclusionsText ?? "") + _additionalArguments = State(initialValue: job?.additionalArguments ?? "") + _isEnabled = State(initialValue: job?.isEnabled ?? true) + _scheduleEnabled = State(initialValue: job?.scheduleEnabled ?? false) + _scheduleKind = State(initialValue: job?.scheduleKind ?? .daily) + _scheduleStartAt = State(initialValue: job?.scheduleStartAt ?? Date().addingTimeInterval(3600)) + _scheduleIntervalMinutes = State(initialValue: job?.scheduleIntervalMinutes ?? 30) + _watchEnabled = State(initialValue: job?.watchEnabled ?? false) + _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) + _retryCount = State(initialValue: job?.retryCount ?? 2) + _retryDelaySeconds = State(initialValue: job?.retryDelaySeconds ?? 10) + _notifyOnFailure = State(initialValue: job?.notifyOnFailure ?? true) + _notifyOnSuccess = State(initialValue: job?.notifyOnSuccess ?? false) + _requireExpectedVolume = State(initialValue: job?.requireExpectedVolume ?? false) + _expectedVolumeIdentifier = State(initialValue: job?.expectedVolumeIdentifier ?? "") + _minimumFreeSpaceMB = State(initialValue: job?.minimumFreeSpaceMB ?? 512) + _validationMessage = State(initialValue: nil) + } + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + Form { + Section("Job") { + TextField("Name", text: $name) + Toggle("Enabled", isOn: $isEnabled) + } + + Section("Locations") { + PathField(title: "Source", path: $sourcePath) + Picker("Destination type", selection: $isRemote) { + Text("Local / Mounted Volume").tag(false) + Text("SSH / Remote Mac or Server").tag(true) + } + .pickerStyle(.segmented) + + if isRemote { + TextField("Remote path", text: $destinationPath, prompt: Text("/home/mag/backup or ~/backup")) + .textFieldStyle(.roundedBorder) + } else { + PathField(title: "Destination", path: $destinationPath) + } + } + + if !isRemote { + Section("Destination Safety") { + Stepper("Minimum free space: \(minimumFreeSpaceMB) MB", value: $minimumFreeSpaceMB, in: 0...1_048_576, step: 256) + Toggle("Require this exact destination volume", isOn: $requireExpectedVolume) + if requireExpectedVolume { + HStack { + Text(expectedVolumeIdentifier.isEmpty ? "No volume captured" : expectedVolumeIdentifier) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Capture Current Volume") { + let expanded = (destinationPath as NSString).expandingTildeInPath + if FileManager.default.fileExists(atPath: expanded), let info = SyncVolumeInspector.info(forPath: expanded) { + expectedVolumeIdentifier = info.identifier + } else { + validationMessage = "Mount and select the actual destination volume before capturing its identity." + } + } + } + Text("Prevents a missing external drive from being replaced by an unintended local folder with the same path.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if isRemote { + Section("SSH") { + TextField("Host", text: $remoteHost, prompt: Text("pegasus.local")) + TextField("User", text: $remoteUser, prompt: Text(defaultSSHUsername.isEmpty ? NSUserName() : defaultSSHUsername)) + Stepper("Port: \(remotePort)", value: $remotePort, in: 1...65535) + HStack { + TextField("Private SSH key", text: $sshKeyPath, prompt: Text("~/.ssh/id_ed25519 or ~/.ssh/id_rsa")) + Button("Choose…") { + if let selected = FilePicker.chooseFile() { sshKeyPath = selected } + } + } + if sshKeyPath.hasSuffix(".pub") { + Label("Select the private key, not the .pub public key.", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + Section { + Toggle("Run this job on a schedule", isOn: $scheduleEnabled) + if scheduleEnabled { + Picker("Frequency", selection: $scheduleKind) { + ForEach(SyncScheduleKind.allCases.filter { $0 != .manual }) { kind in + Label(kind.title, systemImage: kind.systemImage).tag(kind) + } + } + + DatePicker( + scheduleKind == .once ? "Run date and time" : "Starting", + selection: $scheduleStartAt, + displayedComponents: scheduleKind == .once ? [.date, .hourAndMinute] : [.date, .hourAndMinute] + ) + + if scheduleKind == .custom { + Stepper("Every \(scheduleIntervalMinutes) minutes", value: $scheduleIntervalMinutes, in: 1...525600) + } + + Label(schedulePreview, systemImage: "calendar.badge.clock") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Schedule") + } footer: { + Text("Scheduled jobs run silently and are recorded in History. They continue while Rsync Studio is running, even when its window is in the background.") + } + .id(JobEditorSection.schedule) + + Section { + Toggle("Watch for file changes", isOn: $watchEnabled) + if watchEnabled { + HStack { + TextField("Watch path", text: $watchPath, prompt: Text(sourcePath.isEmpty ? "Uses the job source" : sourcePath)) + Menu("Choose…") { + Button("Choose Folder…") { + if let selected = FilePicker.chooseDirectory() { watchPath = selected } + } + Button("Choose File…") { + if let selected = FilePicker.chooseFile() { watchPath = selected } + } + } + } + Stepper("Wait \(watchDebounceSeconds) seconds after changes", value: $watchDebounceSeconds, in: 1...60) + Label("Changes are monitored with macOS FSEvents. Bursts of edits are combined into one sync.", systemImage: "waveform.path.ecg") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("File Watching") + } footer: { + Text("Leave the watch path empty to watch the source. If the source is a single file, only that file is synced; if it is a folder, the configured folder job runs when anything inside changes.") + } + .id(JobEditorSection.fileWatch) + + Section("Reliability & Notifications") { + Stepper("Automatic retries: \(retryCount)", value: $retryCount, in: 0...10) + Stepper("Initial retry delay: \(retryDelaySeconds) seconds", value: $retryDelaySeconds, in: 1...300) + Text("Retries apply to scheduled and file-watch runs and use an increasing delay between attempts.") + .font(.caption) + .foregroundStyle(.secondary) + Toggle("Notify when a run fails", isOn: $notifyOnFailure) + Toggle("Notify when a run succeeds", isOn: $notifyOnSuccess) + } + + Section("Rsync Options") { + 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) + } + + Section("Exclusions") { + TextEditor(text: $exclusionsText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 90) + Text("One exclusion pattern per line.") + .font(.caption) + .foregroundStyle(.secondary) + } + + 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) + .navigationTitle(existingJob == nil ? "New Sync Job" : "Edit Sync Job") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canSave) + } + } + .task { + guard let initialSection else { return } + try? await Task.sleep(for: .milliseconds(120)) + withAnimation { proxy.scrollTo(initialSection, anchor: .top) } + } + } + } + .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) + } + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !sourcePath.isEmpty && + !destinationPath.isEmpty && + (!isRemote || !remoteHost.isEmpty) + } + + private var schedulePreview: String { + let previewJob = SyncJob() + previewJob.scheduleEnabled = scheduleEnabled + previewJob.scheduleKind = scheduleKind + previewJob.scheduleStartAt = scheduleStartAt + previewJob.scheduleIntervalMinutes = scheduleIntervalMinutes + if let next = previewJob.calculateNextRun() { + return "Next run: \(next.formatted(date: .abbreviated, time: .shortened))" + } + return "No future run is currently scheduled." + } + + private func save() { + let job = existingJob ?? SyncJob() + job.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + job.sourcePath = sourcePath + job.destinationPath = destinationPath + job.isRemote = isRemote + job.remoteHost = remoteHost + job.remoteUser = remoteUser + job.remotePort = remotePort + job.sshKeyPath = sshKeyPath + job.archive = archive + job.compress = compress + job.deleteExtraneous = deleteExtraneous + job.preservePermissions = preservePermissions + job.preserveExtendedAttributes = preserveExtendedAttributes + job.showProgress = showProgress + job.exclusionsText = exclusionsText + job.additionalArguments = additionalArguments + job.isEnabled = isEnabled + job.scheduleEnabled = scheduleEnabled + job.scheduleKind = scheduleEnabled ? scheduleKind : .manual + job.scheduleStartAt = scheduleStartAt + job.scheduleIntervalMinutes = scheduleIntervalMinutes + job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) + job.watchEnabled = watchEnabled + job.watchPath = watchPath + job.watchDebounceSeconds = watchDebounceSeconds + job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false + job.retryCount = retryCount + job.retryDelaySeconds = retryDelaySeconds + job.notifyOnFailure = notifyOnFailure + job.notifyOnSuccess = notifyOnSuccess + job.requireExpectedVolume = !isRemote && requireExpectedVolume + job.expectedVolumeIdentifier = !isRemote && requireExpectedVolume ? expectedVolumeIdentifier : "" + job.minimumFreeSpaceMB = minimumFreeSpaceMB + job.updatedAt = Date() + + 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)" + } + } +} diff --git a/Rsync Studio/Views/Jobs/JobListView.swift b/Rsync Studio/Views/Jobs/JobListView.swift new file mode 100644 index 0000000..f26b695 --- /dev/null +++ b/Rsync Studio/Views/Jobs/JobListView.swift @@ -0,0 +1,523 @@ +import SwiftUI +import SwiftData +import AppKit +import UniformTypeIdentifiers + +struct JobListView: View { + @Environment(\.modelContext) private var modelContext + @EnvironmentObject private var automation: BackgroundSyncCoordinator + @Query(sort: \SyncJob.name) private var jobs: [SyncJob] + @Binding var selection: SyncJob? + @Binding var showingNewJob: Bool + @State private var searchText = "" + @State private var filter: JobFilter = .all + @State private var persistenceError: String? + @State private var transferMessage: String? + + var body: some View { + VStack(spacing: 0) { + dashboardHeader + + Picker("Filter", selection: $filter) { + ForEach(JobFilter.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 14) + .padding(.bottom, 10) + + Divider() + + if filteredJobs.isEmpty { + emptyState + } else { + List(selection: $selection) { + ForEach(filteredJobs) { job in + JobRow(job: job, isRunning: automationActivityMatches(job)) + .tag(job) + .listRowInsets(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)) + .contextMenu { + Button("Open Job") { selection = job } + Button("Duplicate") { duplicate(job) } + Divider() + Button(job.isEnabled ? "Disable" : "Enable") { + job.isEnabled.toggle() + job.updatedAt = Date() + saveAndRefresh() + } + Button("Delete", role: .destructive) { delete(job) } + } + } + } + .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.") + } + .alert("Job Configuration", isPresented: Binding( + get: { transferMessage != nil }, + set: { if !$0 { transferMessage = nil } } + )) { + Button("OK", role: .cancel) { transferMessage = nil } + } message: { + Text(transferMessage ?? "") + } + } + + private var dashboardHeader: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Jobs") + .font(.headline) + Text("\(jobs.count) configured job\(jobs.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + statusPill + + Menu { + Button("Import Jobs…", systemImage: "square.and.arrow.down") { importJobs() } + Button("Export Jobs…", systemImage: "square.and.arrow.up") { exportJobs() } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .help("Import or export job configurations") + + Button { + showingNewJob = true + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderedProminent) + .help("New Sync Job") + } + + HStack(spacing: 8) { + TextField("Search jobs", text: $searchText) + .textFieldStyle(.roundedBorder) + + if automation.isSyncAllRunning { + Button(role: .destructive) { + automation.cancelSyncAll() + } label: { + Label("Cancel All", systemImage: "stop.fill") + } + .buttonStyle(.bordered) + .help("Cancel the current Sync All batch") + } else { + Button { + automation.syncAllEnabledJobs() + } label: { + Label("Sync All", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.bordered) + .disabled(jobs.allSatisfy { !$0.isEnabled }) + .help("Run every enabled sync job in sequence") + } + } + + HStack(spacing: 9) { + metricCard(value: automation.scheduledJobCount, title: "Scheduled", icon: "calendar.badge.clock", colour: .blue) + metricCard(value: automation.watchedJobCount, title: "Watching", icon: "eye.fill", colour: .purple) + } + + if automation.isSyncAllRunning { + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: Double(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped), total: Double(max(1, automation.syncAllTotal))) + HStack { + Text("Sync All · \(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped) of \(automation.syncAllTotal)") + Spacer() + Text("✓ \(automation.syncAllCompleted) ! \(automation.syncAllFailed) – \(automation.syncAllSkipped)") + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .padding(14) + } + + @ViewBuilder + private var statusPill: some View { + if !automation.isNetworkAvailable { + Label("Offline", systemImage: "wifi.slash") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.orange.opacity(0.10), in: Capsule()) + } else if automation.activity == .idle { + Label("Ready", systemImage: "checkmark.circle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.green.opacity(0.10), in: Capsule()) + } else { + Label(automation.activityDisplayTitle, systemImage: "arrow.triangle.2.circlepath") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.accentColor.opacity(0.10), in: Capsule()) + } + } + + private func metricCard(value: Int, title: String, icon: String, colour: Color) -> some View { + HStack(spacing: 9) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(colour.opacity(0.11)) + Image(systemName: icon) + .foregroundStyle(colour) + } + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 0) { + Text("\(value)") + .font(.headline) + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(9) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.secondary.opacity(0.10)) + } + } + + private var emptyState: some View { + ContentUnavailableView( + searchText.isEmpty ? "No Jobs" : "No Matching Jobs", + systemImage: searchText.isEmpty ? "arrow.left.arrow.right.square" : "magnifyingglass", + description: Text(searchText.isEmpty ? "Create a sync job to get started." : "Try a different search or filter.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var filteredJobs: [SyncJob] { + jobs.filter { job in + let matchesSearch = searchText.isEmpty || + job.name.localizedCaseInsensitiveContains(searchText) || + job.sourcePath.localizedCaseInsensitiveContains(searchText) || + job.destinationDisplay.localizedCaseInsensitiveContains(searchText) + + let matchesFilter: Bool + switch filter { + case .all: matchesFilter = true + case .scheduled: matchesFilter = job.scheduleEnabled && job.scheduleKind != .manual + case .watching: matchesFilter = job.watchEnabled + case .attention: matchesFilter = job.lastExitCode != nil && job.lastExitCode != 0 + } + return matchesSearch && matchesFilter + } + } + + private func automationActivityMatches(_ job: SyncJob) -> Bool { + if case .running(let name) = automation.activity { return name == job.name } + return false + } + + private func duplicate(_ job: SyncJob) { + let copy = SyncJob(name: job.name + " Copy", sourcePath: job.sourcePath, destinationPath: job.destinationPath) + copy.isRemote = job.isRemote + copy.remoteHost = job.remoteHost + copy.remoteUser = job.remoteUser + copy.remotePort = job.remotePort + copy.sshKeyPath = job.sshKeyPath + copy.archive = job.archive + copy.compress = job.compress + copy.deleteExtraneous = job.deleteExtraneous + copy.preservePermissions = job.preservePermissions + copy.preserveExtendedAttributes = job.preserveExtendedAttributes + copy.showProgress = job.showProgress + copy.exclusionsText = job.exclusionsText + copy.additionalArguments = job.additionalArguments + copy.isEnabled = job.isEnabled + copy.scheduleEnabled = job.scheduleEnabled + copy.scheduleKind = job.scheduleKind + copy.scheduleStartAt = job.scheduleStartAt + copy.scheduleIntervalMinutes = job.scheduleIntervalMinutes + copy.nextRunAt = copy.calculateNextRun(after: Date().addingTimeInterval(-1)) + copy.watchEnabled = job.watchEnabled + copy.watchPath = job.watchPath + copy.watchDebounceSeconds = job.watchDebounceSeconds + copy.allowUnattendedDelete = job.allowUnattendedDelete + copy.retryCount = job.retryCount + copy.retryDelaySeconds = job.retryDelaySeconds + copy.notifyOnFailure = job.notifyOnFailure + copy.notifyOnSuccess = job.notifyOnSuccess + copy.requireExpectedVolume = job.requireExpectedVolume + copy.expectedVolumeIdentifier = job.expectedVolumeIdentifier + copy.minimumFreeSpaceMB = job.minimumFreeSpaceMB + modelContext.insert(copy) + saveAndRefresh() + selection = copy + } + + private func exportJobs() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "Rsync Studio-Jobs.json" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let document = JobTransferDocument(version: 1, exportedAt: Date(), jobs: jobs.map(JobTransferJob.init)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(document).write(to: url, options: .atomic) + transferMessage = "Exported \(jobs.count) job configuration(s). SSH private-key paths are intentionally excluded." + } catch { persistenceError = "Could not export jobs: \(error.localizedDescription)" } + } + + private func importJobs() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document = try decoder.decode(JobTransferDocument.self, from: Data(contentsOf: url)) + guard document.version == 1 else { throw JobTransferError.unsupportedVersion } + var count = 0 + for item in document.jobs { + let job = item.makeJob() + modelContext.insert(job) + count += 1 + } + try modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + transferMessage = "Imported \(count) job configuration(s). Remote jobs are disabled until you select a private key or confirm SSH-agent access." + } catch { persistenceError = "Could not import jobs: \(error.localizedDescription)" } + } + + private func delete(_ job: SyncJob) { + if selection?.id == job.id { selection = nil } + modelContext.delete(job) + saveAndRefresh() + } + + private func saveAndRefresh() { + do { + try modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + } catch { + persistenceError = error.localizedDescription + } + } +} + +private struct JobRow: View { + let job: SyncJob + let isRunning: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(iconColour.opacity(0.12)) + Image(systemName: job.isRemote ? "server.rack" : "folder.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(iconColour) + } + .frame(width: 38, height: 38) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + Text(job.name) + .font(.body.weight(.semibold)) + .lineLimit(1) + + if isRunning { + AnimatedSyncIndicator(size: 15, lineWidth: 2.2) + } + + Spacer() + statusIcon + } + + pathSummary + + HStack(spacing: 6) { + if job.scheduleEnabled && job.scheduleKind != .manual { + miniBadge(job.scheduleKind.title, icon: "calendar.badge.clock", colour: .blue) + } + if job.watchEnabled { + miniBadge("Watching", icon: "eye.fill", colour: .purple) + } + if !job.isEnabled { + miniBadge("Disabled", icon: "pause.fill", colour: .secondary) + } + } + + Text(timingText) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + .padding(10) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.09)) + } + .opacity(job.isEnabled ? 1 : 0.62) + } + + private var pathSummary: some View { + HStack(spacing: 5) { + Text(shortPath(job.sourcePath)) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "arrow.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + Text(shortPath(job.destinationDisplay)) + .lineLimit(1) + .truncationMode(.middle) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var statusIcon: some View { + if isRunning { + Image(systemName: "arrow.triangle.2.circlepath.circle.fill") + .foregroundStyle(Color.accentColor) + } else if job.lastExitCode == 0 { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else if job.lastExitCode != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } else { + Image(systemName: "circle.dashed") + .foregroundStyle(.tertiary) + } + } + + private var timingText: String { + if let next = job.nextRunAt, job.scheduleEnabled { + return "Next run \(next.formatted(.relative(presentation: .named)))" + } + if let last = job.lastRunAt { + return "Last run \(last.formatted(.relative(presentation: .named)))" + } + return "Not run yet" + } + + private var iconColour: Color { + if !job.isEnabled { return .secondary } + return job.isRemote ? .indigo : .blue + } + + private func shortPath(_ path: String) -> String { + guard path.count > 42 else { return path } + return "…" + path.suffix(41) + } + + private func miniBadge(_ title: String, icon: String, colour: Color) -> some View { + Label(title, systemImage: icon) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(colour) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(colour.opacity(0.10), in: Capsule()) + } +} + +private enum JobFilter: String, CaseIterable, Identifiable { + case all, scheduled, watching, attention + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All" + case .scheduled: return "Scheduled" + case .watching: return "Watching" + case .attention: return "Issues" + } + } +} + + +private struct JobTransferDocument: Codable { + let version: Int + let exportedAt: Date + let jobs: [JobTransferJob] +} + +private struct JobTransferJob: Codable { + let name, sourcePath, destinationPath: String + let isRemote: Bool + let remoteHost, remoteUser: String + let remotePort: Int + let archive, compress, deleteExtraneous, preservePermissions, preserveExtendedAttributes, showProgress: Bool + let exclusionsText, additionalArguments: String + let isEnabled: Bool + let scheduleEnabled: Bool + let scheduleKindRaw: String + let scheduleStartAt: Date + let scheduleIntervalMinutes: Int + let watchEnabled: Bool + let watchPath: String + let watchDebounceSeconds: Int + let allowUnattendedDelete: Bool + let retryCount, retryDelaySeconds: Int + let notifyOnFailure, notifyOnSuccess: Bool + let requireExpectedVolume: Bool + let expectedVolumeIdentifier: String + let minimumFreeSpaceMB: Int + + init(_ job: SyncJob) { + name = job.name; sourcePath = job.sourcePath; destinationPath = job.destinationPath + isRemote = job.isRemote; remoteHost = job.remoteHost; remoteUser = job.remoteUser; remotePort = job.remotePort + archive = job.archive; compress = job.compress; deleteExtraneous = job.deleteExtraneous + preservePermissions = job.preservePermissions; preserveExtendedAttributes = job.preserveExtendedAttributes; showProgress = job.showProgress + exclusionsText = job.exclusionsText; additionalArguments = job.additionalArguments; isEnabled = job.isEnabled + scheduleEnabled = job.scheduleEnabled; scheduleKindRaw = job.scheduleKindRaw; scheduleStartAt = job.scheduleStartAt; scheduleIntervalMinutes = job.scheduleIntervalMinutes + watchEnabled = job.watchEnabled; watchPath = job.watchPath; watchDebounceSeconds = job.watchDebounceSeconds + allowUnattendedDelete = job.allowUnattendedDelete; retryCount = job.retryCount; retryDelaySeconds = job.retryDelaySeconds + notifyOnFailure = job.notifyOnFailure; notifyOnSuccess = job.notifyOnSuccess + requireExpectedVolume = job.requireExpectedVolume; expectedVolumeIdentifier = job.expectedVolumeIdentifier; minimumFreeSpaceMB = job.minimumFreeSpaceMB + } + + func makeJob() -> SyncJob { + let job = SyncJob(name: name, sourcePath: sourcePath, destinationPath: destinationPath) + job.isRemote = isRemote; job.remoteHost = remoteHost; job.remoteUser = remoteUser; job.remotePort = remotePort; job.sshKeyPath = "" + job.archive = archive; job.compress = compress; job.deleteExtraneous = deleteExtraneous; job.preservePermissions = preservePermissions + job.preserveExtendedAttributes = preserveExtendedAttributes; job.showProgress = showProgress; job.exclusionsText = exclusionsText; job.additionalArguments = additionalArguments + job.isEnabled = isRemote ? false : isEnabled + job.scheduleEnabled = isRemote ? false : scheduleEnabled; job.scheduleKindRaw = scheduleKindRaw; job.scheduleStartAt = scheduleStartAt; job.scheduleIntervalMinutes = scheduleIntervalMinutes + job.nextRunAt = job.scheduleEnabled ? job.calculateNextRun(after: Date().addingTimeInterval(-1)) : nil + job.watchEnabled = isRemote ? false : watchEnabled; job.watchPath = watchPath; job.watchDebounceSeconds = watchDebounceSeconds + job.allowUnattendedDelete = allowUnattendedDelete; job.retryCount = retryCount; job.retryDelaySeconds = retryDelaySeconds + job.notifyOnFailure = notifyOnFailure; job.notifyOnSuccess = notifyOnSuccess; job.requireExpectedVolume = requireExpectedVolume + job.expectedVolumeIdentifier = expectedVolumeIdentifier; job.minimumFreeSpaceMB = minimumFreeSpaceMB + return job + } +} + +private enum JobTransferError: LocalizedError { + case unsupportedVersion + var errorDescription: String? { "This job configuration file uses an unsupported schema version." } +} diff --git a/Rsync Studio/Views/LicenseRequiredView.swift b/Rsync Studio/Views/LicenseRequiredView.swift new file mode 100644 index 0000000..a9e56dc --- /dev/null +++ b/Rsync Studio/Views/LicenseRequiredView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import AppKit + +struct LicenseRequiredView: View { + @EnvironmentObject private var licensing: LicensingManager + @Environment(\.openSettings) private var openSettings + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 54, weight: .medium)) + .foregroundStyle(Color.accentColor) + + VStack(spacing: 7) { + Text(licensing.statusTitle) + .font(.title2.bold()) + Text(licensing.statusDetail) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + } + + HStack(spacing: 12) { + Button("Buy Rsync Studio…") { openPurchasePage() } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Button("Open Licence Settings") { openSettings() } + .controlSize(.large) + } + + Text("Your jobs and history remain on this Mac. Rsync Studio does not delete local data when a trial expires or a licence is removed.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + } + .padding(48) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func openPurchasePage() { + guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String, + let url = URL(string: raw), + url.scheme?.lowercased() == "https" else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/Rsync Studio/Views/LicenseView.swift b/Rsync Studio/Views/LicenseView.swift new file mode 100644 index 0000000..8ae4fbc --- /dev/null +++ b/Rsync Studio/Views/LicenseView.swift @@ -0,0 +1,145 @@ +import SwiftUI +import AppKit + +struct LicenseView: View { + @EnvironmentObject private var licensing: LicensingManager + @State private var licenceKey = "" + @State private var signedToken = "" + @State private var errorMessage: String? + @State private var showingTokenEntry = false + + var body: some View { + Form { + Section("Licence Status") { + HStack(spacing: 12) { + Image(systemName: statusIcon) + .font(.title2) + .foregroundStyle(licensing.canUseApp ? Color.green : Color.orange) + VStack(alignment: .leading, spacing: 3) { + Text(licensing.statusTitle).font(.headline) + Text(licensing.statusDetail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + LabeledContent("Installation ID", value: licensing.installationID) + .textSelection(.enabled) + Text("The installation ID is a random UUID stored in macOS Keychain. Rsync Studio does not use your Mac serial number or other hardware identifiers.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if case .licensed(let payload) = licensing.state { + Section("Current Licence") { + LabeledContent("Edition", value: payload.edition) + LabeledContent("Licence ID", value: payload.licenseID) + LabeledContent("Licensed to", value: payload.displayOwner) + if let expiresAt = payload.expiresAt { + LabeledContent("Expires", value: expiresAt.formatted(date: .long, time: .omitted)) + } else { + LabeledContent("Term", value: "Perpetual") + } + Button("Deactivate This Mac…", role: .destructive) { + Task { await deactivate() } + } + .disabled(licensing.isWorking) + } + } else { + Section("Activate Rsync Studio") { + TextField("Licence key", text: $licenceKey, prompt: Text("XXXX-XXXX-XXXX-XXXX")) + .textContentType(.password) + HStack { + Button { + Task { await activate() } + } label: { + if licensing.isWorking { ProgressView().controlSize(.small) } + else { Label("Activate", systemImage: "checkmark.seal") } + } + .buttonStyle(.borderedProminent) + .disabled(licenceKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || licensing.isWorking) + + Button(showingTokenEntry ? "Hide Signed Token" : "Install Signed Token…") { + showingTokenEntry.toggle() + } + } + Button("Buy Rsync Studio…") { openPurchasePage() } + Text("Buy Personal or Professional online, then enter the licence key delivered to your email. Signed offline licence tokens can also be installed without contacting the activation server.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if showingTokenEntry { + Section("Signed Offline Licence") { + TextEditor(text: $signedToken) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 86) + Button("Install Signed Licence") { installToken() } + .disabled(signedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + if case .invalid = licensing.state { + Section("Recovery") { + Button("Remove Invalid Local Licence") { licensing.removeInvalidLocalLicence() } + Text("This removes only the stored local licence token. Trial dates and the installation identity are not reset.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Section("Privacy") { + Text("Licence activation sends only the licence key, random installation ID, app version and platform to the licensing service. Sync paths, filenames, SSH hosts, credentials, job history and file contents are never included.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .alert("Rsync Studio Licence", isPresented: Binding( + get: { errorMessage != nil || licensing.lastMessage != nil }, + set: { if !$0 { errorMessage = nil; licensing.lastMessage = nil } } + )) { + Button("OK", role: .cancel) { errorMessage = nil; licensing.lastMessage = nil } + } message: { + Text(errorMessage ?? licensing.lastMessage ?? "") + } + } + + private var statusIcon: String { + switch licensing.state { + case .licensed: "checkmark.seal.fill" + case .trial: "clock.badge.checkmark" + case .expired: "clock.badge.exclamationmark" + case .invalid: "exclamationmark.shield.fill" + } + } + + private func activate() async { + do { + try await licensing.activate(licenseKey: licenceKey) + licenceKey = "" + } catch { errorMessage = error.localizedDescription } + } + + private func deactivate() async { + do { try await licensing.deactivate() } + catch { errorMessage = error.localizedDescription } + } + + private func installToken() { + do { + try licensing.installSignedToken(signedToken) + signedToken = "" + showingTokenEntry = false + } catch { errorMessage = error.localizedDescription } + } + + private func openPurchasePage() { + guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String, + let url = URL(string: raw), + url.scheme?.lowercased() == "https" else { + errorMessage = "The Rsync Studio purchase page is not configured in this build." + return + } + NSWorkspace.shared.open(url) + } +} diff --git a/Rsync Studio/Views/Runs/RunHistoryView.swift b/Rsync Studio/Views/Runs/RunHistoryView.swift new file mode 100644 index 0000000..a3fb1c4 --- /dev/null +++ b/Rsync Studio/Views/Runs/RunHistoryView.swift @@ -0,0 +1,199 @@ +import SwiftUI +import SwiftData +import AppKit +import UniformTypeIdentifiers + +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? + @State private var searchText = "" + @State private var filter: HistoryFilter = .all + let onBack: () -> Void + + var body: some View { + HSplitView { + VStack(spacing: 0) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("Recent Runs") + .font(.headline) + Text("\(runs.count) recorded") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Menu { + Button("Export Selected…") { exportRuns(selection.map { [$0] } ?? []) } + .disabled(selection == nil) + Button("Export All…") { exportRuns(runs) } + .disabled(runs.isEmpty) + } label: { + Image(systemName: "square.and.arrow.up") + } + .menuStyle(.borderlessButton) + .help("Export run logs") + + Button("Clear", role: .destructive) { + runs.forEach(modelContext.delete) + selection = nil + saveHistoryChanges() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(runs.isEmpty) + } + .padding(14) + + VStack(spacing: 8) { + TextField("Search history", text: $searchText) + .textFieldStyle(.roundedBorder) + Picker("History filter", selection: $filter) { + ForEach(HistoryFilter.allCases) { item in Text(item.title).tag(item) } + } + .pickerStyle(.segmented) + .labelsHidden() + } + .padding(.horizontal, 14) + .padding(.bottom, 10) + + Divider() + + if filteredRuns.isEmpty { + ContentUnavailableView( + "No Run History", + systemImage: "clock.arrow.circlepath", + description: Text(runs.isEmpty ? "Completed, failed and background synchronisations will appear here." : "No runs match the current search or filter.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(selection: $selection) { + ForEach(filteredRuns) { run in + HStack(spacing: 11) { + Image(systemName: statusIcon(run)) + .foregroundStyle(statusColour(run)) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(run.jobName) + .fontWeight(.semibold) + .lineLimit(1) + Spacer() + StatusBadge(run: run) + } + + Text(run.startedAt, format: .dateTime.day().month(.abbreviated).year().hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 6) { + Label(run.trigger.title, systemImage: run.trigger.systemImage) + .font(.caption2.weight(.semibold)) + .foregroundStyle(run.trigger == .watched ? Color.purple : Color.blue) + if run.wasDryRun { + Text("Dry run") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.blue) + } + } + } + } + .padding(.vertical, 3) + .tag(run) + .contextMenu { + Button("Delete Log", role: .destructive) { + if selection?.id == run.id { selection = nil } + modelContext.delete(run) + saveHistoryChanges() + } + } + } + } + .listStyle(.sidebar) + } + } + .frame(minWidth: 300, idealWidth: 340, maxWidth: 390) + + Group { + if let selection { + RunLogView(run: selection) + } else { + ContentUnavailableView( + "Select a Run", + systemImage: "clock.arrow.circlepath", + description: Text("Select a completed or failed run to inspect its summary and full log.") + ) + } + } + .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 var filteredRuns: [SyncRun] { + runs.filter { run in + let matchesSearch = searchText.isEmpty || run.jobName.localizedCaseInsensitiveContains(searchText) || run.output.localizedCaseInsensitiveContains(searchText) || run.errorOutput.localizedCaseInsensitiveContains(searchText) + let matchesFilter: Bool + switch filter { + case .all: matchesFilter = true + case .failed: matchesFilter = !run.isRunning && !run.succeeded && !run.wasCancelled + case .successful: matchesFilter = run.succeeded + case .automatic: matchesFilter = run.trigger == .scheduled || run.trigger == .watched + } + return matchesSearch && matchesFilter + } + } + + private func exportRuns(_ items: [SyncRun]) { + guard !items.isEmpty else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.plainText] + panel.nameFieldStringValue = items.count == 1 ? "Rsync Studio-\(items[0].jobName)-Run.txt" : "Rsync Studio-History.txt" + guard panel.runModal() == .OK, let url = panel.url else { return } + let text = items.map(\.exportText).joined(separator: "\n\n" + String(repeating: "=", count: 72) + "\n\n") + do { try text.write(to: url, atomically: true, encoding: .utf8) } + catch { persistenceError = "Could not export history: \(error.localizedDescription)" } + } + + private func saveHistoryChanges() { + do { try modelContext.save() } + catch { persistenceError = error.localizedDescription } + } + + private func statusIcon(_ run: SyncRun) -> String { + if run.isRunning { return "arrow.triangle.2.circlepath" } + if run.wasCancelled { return "xmark.circle.fill" } + return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + } + + private func statusColour(_ run: SyncRun) -> Color { + if run.isRunning { return .blue } + if run.wasCancelled { return .secondary } + return run.succeeded ? .green : .orange + } +} + + +private enum HistoryFilter: String, CaseIterable, Identifiable { + case all, failed, successful, automatic + var id: String { rawValue } + var title: String { + switch self { + case .all: return "All" + case .failed: return "Failed" + case .successful: return "Success" + case .automatic: return "Automatic" + } + } +} diff --git a/Rsync Studio/Views/Runs/RunLogView.swift b/Rsync Studio/Views/Runs/RunLogView.swift new file mode 100644 index 0000000..5d80a95 --- /dev/null +++ b/Rsync Studio/Views/Runs/RunLogView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct RunLogView: View { + let run: SyncRun + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(run.jobName).font(.title.bold()) + Text(run.startedAt.formatted(date: .abbreviated, time: .standard)) + .foregroundStyle(.secondary) + } + Spacer() + StatusBadge(run: run) + } + + if let duration = run.duration { + LabeledContent("Duration", value: Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated))) + } + if let exitCode = run.exitCode { + LabeledContent("Exit code", value: String(exitCode)) + } + LabeledContent("Mode", value: run.wasDryRun ? "Dry Run" : "Live Run") + + console(title: "Command", text: run.command) + if !run.output.isEmpty { console(title: "Output", text: run.output) } + if !run.errorOutput.isEmpty { console(title: "Errors", text: run.errorOutput) } + } + .padding(28) + } + .navigationTitle("Run Log") + } + + private func console(title: String, text: String) -> some View { + GroupBox(title) { + ScrollView(.horizontal) { + Text(text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Rsync Studio/Views/ScheduleCalendarView.swift b/Rsync Studio/Views/ScheduleCalendarView.swift new file mode 100644 index 0000000..3b2ffa5 --- /dev/null +++ b/Rsync Studio/Views/ScheduleCalendarView.swift @@ -0,0 +1,220 @@ +import SwiftUI +import SwiftData + +struct ScheduleCalendarView: View { + @Query(sort: \SyncJob.name) private var jobs: [SyncJob] + @State private var displayedMonth = Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date() + @State private var selectedDay = Date() + @State private var editingJob: SyncJob? + + private let calendar = Calendar.current + private let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7) + + var body: some View { + HStack(spacing: 0) { + VStack(spacing: 0) { + monthHeader + Divider() + calendarGrid + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + agenda + .frame(width: 320) + .background(.bar) + } + .sheet(item: $editingJob) { job in + JobEditorView(job: job) + } + } + + private var monthHeader: some View { + HStack { + Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") } + .buttonStyle(.bordered) + VStack(alignment: .leading, spacing: 2) { + Text(displayedMonth.formatted(.dateTime.month(.wide).year())) + .font(.title2.bold()) + Text("\(monthOccurrences.count) scheduled runs") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Today") { + displayedMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date() + selectedDay = Date() + } + .buttonStyle(.borderedProminent) + Button { moveMonth(1) } label: { Image(systemName: "chevron.right") } + .buttonStyle(.bordered) + } + .padding(18) + } + + private var calendarGrid: some View { + VStack(spacing: 8) { + LazyVGrid(columns: columns, spacing: 6) { + ForEach(calendar.shortWeekdaySymbols, id: \.self) { symbol in + Text(symbol.uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + + ForEach(monthCells.indices, id: \.self) { index in + if let date = monthCells[index] { + dayCell(date) + } else { + Color.clear.frame(minHeight: 86) + } + } + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private func dayCell(_ date: Date) -> some View { + let occurrences = occurrences(on: date) + let selected = calendar.isDate(date, inSameDayAs: selectedDay) + let today = calendar.isDateInToday(date) + + return Button { + selectedDay = date + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("\(calendar.component(.day, from: date))") + .font(.body.weight(today ? .bold : .medium)) + .foregroundStyle(today ? Color.accentColor : .primary) + Spacer() + if !occurrences.isEmpty { + Text("\(occurrences.count)") + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.14), in: Capsule()) + } + } + + ForEach(Array(occurrences.prefix(2)), id: \.id) { item in + HStack(spacing: 4) { + Circle().fill(Color.accentColor).frame(width: 5, height: 5) + Text(item.job.name) + .font(.caption2) + .lineLimit(1) + Spacer() + Text(item.date.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(9) + .frame(maxWidth: .infinity, minHeight: 86, alignment: .topLeading) + .background(selected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(selected ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.12))) + } + .buttonStyle(.plain) + } + + private var agenda: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 3) { + Text("Agenda").font(.title3.bold()) + Text(selectedDay.formatted(date: .complete, time: .omitted)) + .font(.caption) + .foregroundStyle(.secondary) + } + + let items = occurrences(on: selectedDay) + if items.isEmpty { + ContentUnavailableView( + "No Scheduled Runs", + systemImage: "calendar", + description: Text("Select another day or add a schedule to a sync job.") + ) + } else { + ScrollView { + LazyVStack(spacing: 10) { + ForEach(items, id: \.id) { item in + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: item.job.isRemote ? "server.rack" : "folder.fill") + .foregroundStyle(Color.accentColor) + Text(item.job.name).fontWeight(.semibold) + Spacer() + } + Label(item.date.formatted(date: .omitted, time: .shortened), systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + Text(item.job.destinationDisplay) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack { + Spacer() + Button("Edit Schedule") { editingJob = item.job } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + } + } + Spacer() + } + .padding(18) + } + + private var monthInterval: DateInterval { + calendar.dateInterval(of: .month, for: displayedMonth) ?? DateInterval(start: displayedMonth, duration: 2_592_000) + } + + private var monthCells: [Date?] { + let interval = monthInterval + let days = calendar.range(of: .day, in: .month, for: interval.start) ?? 1..<2 + let weekday = calendar.component(.weekday, from: interval.start) + let leading = (weekday - calendar.firstWeekday + 7) % 7 + var cells = Array(repeating: nil, count: leading) + for day in days { + if let date = calendar.date(byAdding: .day, value: day - 1, to: interval.start) { cells.append(date) } + } + while cells.count % 7 != 0 { cells.append(nil) } + return cells + } + + private var monthOccurrences: [CalendarOccurrence] { + scheduledJobs.flatMap { job in + job.occurrences(from: monthInterval.start, through: monthInterval.end).map { CalendarOccurrence(job: job, date: $0) } + }.sorted { $0.date < $1.date } + } + + private var scheduledJobs: [SyncJob] { + jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual } + } + + private func occurrences(on day: Date) -> [CalendarOccurrence] { + monthOccurrences.filter { calendar.isDate($0.date, inSameDayAs: day) } + } + + private func moveMonth(_ offset: Int) { + if let next = calendar.date(byAdding: .month, value: offset, to: displayedMonth) { + displayedMonth = next + selectedDay = next + } + } +} + +private struct CalendarOccurrence: Identifiable { + let job: SyncJob + let date: Date + var id: String { "\(job.id.uuidString)-\(date.timeIntervalSince1970)" } +}