paddel setup new verifier

This commit is contained in:
Mark Gregory
2026-09-09 14:40:18 +01:00
parent e802dd8cf8
commit d541f492cf
52 changed files with 176 additions and 5962 deletions
@@ -1,370 +0,0 @@
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<UUID>()
private var isProcessingQueue = false
private var watchDebounceTasks: [UUID: Task<Void, Never>] = [:]
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<SyncRun>())
for run in runs where run.finishedAt != nil && run.startedAt < cutoff {
modelContext.delete(run)
}
try modelContext.save()
UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt")
} catch {
lastError = "Could not prune run history: \(error.localizedDescription)"
}
}
private func saveContext(_ operation: String) {
do {
try modelContext.save()
} catch {
lastError = "Database error while \(operation): \(error.localizedDescription)"
}
}
private func fetchJobs() -> [SyncJob] {
(try? modelContext.fetch(FetchDescriptor<SyncJob>(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")
}
@@ -1,94 +0,0 @@
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<FileWatchService>.fromOpaque(info).takeUnretainedValue()
let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? []
for index in 0..<min(count, paths.count) {
service.handle(path: paths[index])
}
}
let flags = FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
guard let stream = FSEventStreamCreate(
nil,
callback,
&context,
paths as CFArray,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.4,
flags
) else { return }
self.stream = stream
FSEventStreamSetDispatchQueue(stream, queue)
FSEventStreamStart(stream)
}
private func handle(path: String) {
let normalized = (path as NSString).standardizingPath
for (jobID, target) in watchedTargets {
let targetPath = (target.targetPath as NSString).standardizingPath
let matches = target.isDirectory
? (normalized == targetPath || normalized.hasPrefix(targetPath + "/"))
: normalized == targetPath
if matches { onChange?(jobID, normalized) }
}
}
}
+157 -1
View File
@@ -52,19 +52,34 @@ final class LicensingManager: ObservableObject {
let installationID: String
}
private struct ValidationRequest: Encodable {
let licenseToken: String
let installationID: String
let appVersion: String
let platform: String
}
private struct ValidationResponse: Decodable {
let active: Bool
let licenseToken: String
}
enum LicensingError: LocalizedError {
case configuration(String)
case invalidLicense(String)
case server(String)
case remoteInactive(String)
var errorDescription: String? {
switch self {
case .configuration(let message), .invalidLicense(let message), .server(let message): message
case .configuration(let message), .invalidLicense(let message), .server(let message), .remoteInactive(let message): message
}
}
}
static let trialLengthDays = 7
static let remoteValidationInterval: TimeInterval = 24 * 60 * 60
static let offlineGracePeriod: TimeInterval = 3 * 24 * 60 * 60
@Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400)))
@Published private(set) var installationID = ""
@@ -76,6 +91,9 @@ final class LicensingManager: ObservableObject {
private let lastSeenAccount = "licensing.trial.lastSeen"
private let installationAccount = "licensing.installationID"
private let tokenAccount = "licensing.token"
private let lastRemoteValidationAccount = "licensing.remoteValidation.lastSuccess"
private let remoteBlockMessageAccount = "licensing.remoteValidation.blockMessage"
private var revalidationInFlight = false
init() {
refresh()
@@ -122,9 +140,81 @@ final class LicensingManager: ObservableObject {
}
}
let blockedMessage = KeychainStore.string(for: remoteBlockMessageAccount)
if !blockedMessage.isEmpty {
state = .invalid(blockedMessage)
return
}
state = trialState(now: now)
}
/// Revalidates only against the Rsync Studio licensing server. This endpoint reads
/// our own database and never calls Paddle, so routine validation has no Paddle API cost.
func revalidateIfNeeded(force: Bool = false, now: Date = Date()) async {
guard !revalidationInFlight else { return }
installationID = loadOrCreateInstallationID()
guard let token = storedLicenseToken, !token.isEmpty else {
refresh(now: now)
return
}
let localPayload: LicensePayload
do {
localPayload = try verify(token: token, now: now)
} catch {
state = .invalid(error.localizedDescription)
return
}
if !force,
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)),
now.timeIntervalSince(lastSuccess) < Self.remoteValidationInterval {
state = .licensed(localPayload)
return
}
guard let baseURL = configuredAPIBaseURL else {
applyOfflineGrace(localPayload: localPayload, now: now)
return
}
// Existing installations created before remote validation was added get a
// three-day migration grace period if the first network check cannot complete.
if Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)) == nil {
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
}
revalidationInFlight = true
defer { revalidationInFlight = false }
do {
let response = try await validateRemotely(token: token, baseURL: baseURL)
guard response.active else {
throw LicensingError.remoteInactive("This Mac has been deactivated from the licence portal.")
}
let refreshedPayload = try verify(token: response.licenseToken, now: now)
guard KeychainStore.set(response.licenseToken, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not update the licence securely in Keychain.")
}
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
state = .licensed(refreshedPayload)
recordLastSeen(now)
} catch LicensingError.remoteInactive(let message) {
KeychainStore.delete(tokenAccount)
KeychainStore.delete(lastRemoteValidationAccount)
_ = KeychainStore.set(message, for: remoteBlockMessageAccount)
state = .invalid(message)
lastMessage = message
} catch {
applyOfflineGrace(localPayload: localPayload, now: now)
}
}
func installSignedToken(_ token: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw LicensingError.invalidLicense("Paste a signed Rsync Studio licence token.") }
@@ -132,6 +222,7 @@ final class LicensingManager: ObservableObject {
guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.")
}
KeychainStore.delete(remoteBlockMessageAccount)
state = .licensed(payload)
lastMessage = "Licence installed successfully."
}
@@ -154,6 +245,8 @@ final class LicensingManager: ObservableObject {
)
let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL)
try installSignedToken(response.licenseToken)
_ = KeychainStore.set(Self.formatDate(Date()), for: lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
lastMessage = "Rsync Studio has been activated on this Mac."
}
@@ -175,12 +268,16 @@ final class LicensingManager: ObservableObject {
guard KeychainStore.delete(tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.")
}
KeychainStore.delete(lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
refresh()
lastMessage = "Licence removed from this Mac."
}
func removeInvalidLocalLicence() {
KeychainStore.delete(tokenAccount)
KeychainStore.delete(lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
refresh()
lastMessage = "Invalid local licence removed."
}
@@ -286,6 +383,65 @@ final class LicensingManager: ObservableObject {
return payload
}
private func validateRemotely(token: String, baseURL: URL) async throws -> ValidationResponse {
let url = baseURL.appending(path: "v1/validate")
guard url.scheme?.lowercased() == "https" else {
throw LicensingError.configuration("Licence API must use HTTPS.")
}
let body = ValidationRequest(
licenseToken: token,
installationID: installationID,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
platform: "macOS"
)
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicensingError.server("The licence server returned an invalid response.")
}
if http.statusCode == 403 {
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
?? "This licence is no longer active on this Mac."
throw LicensingError.remoteInactive(message)
}
guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
?? "Licence server returned HTTP \(http.statusCode)."
throw LicensingError.server(message)
}
do {
return try JSONDecoder().decode(ValidationResponse.self, from: data)
} catch {
throw LicensingError.server("The licence server returned malformed validation data.")
}
} catch let error as LicensingError {
throw error
} catch {
throw LicensingError.server(error.localizedDescription)
}
}
private func applyOfflineGrace(localPayload: LicensePayload, now: Date) {
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount))
if let lastSuccess, now.timeIntervalSince(lastSuccess) <= Self.offlineGracePeriod {
state = .licensed(localPayload)
return
}
state = .invalid("Rsync Studio has been unable to verify this licence for more than 3 days. Connect to the internet and reopen the app.")
}
private func post<Request: Encodable, Response: Decodable>(path: String, body: Request, baseURL: URL) async throws -> Response {
let url = baseURL.appending(path: path)
guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") }
@@ -1,304 +0,0 @@
import Foundation
struct RsyncCommandBuilder {
static func build(job: SyncJob, dryRun: Bool, executable: String) -> [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<String> = [
"-e", "-M", "--rsh", "--rsync-path", "--remote-option",
"--password-file", "--daemon", "--config", "--port", "--address",
"--sockopts", "--remove-source-files", "--copy-as", "--files-from",
"--exclude-from", "--include-from", "--log-file", "--log-file-format",
"--backup-dir", "--temp-dir", "--partial-dir", "--chown", "--usermap",
"--groupmap", "--rsync-path", "--write-batch", "--only-write-batch",
"--read-batch"
]
let blockedPrefixes = [
"--rsh=", "--rsync-path=", "--remote-option=", "--password-file=",
"--config=", "--port=", "--address=", "--sockopts=", "--copy-as=",
"--delete", "--remove-source-files", "--files-from=", "--exclude-from=",
"--include-from=", "--log-file=", "--log-file-format=", "--backup-dir=",
"--temp-dir=", "--partial-dir=", "--chown=", "--usermap=", "--groupmap=",
"--write-batch=", "--only-write-batch=", "--read-batch="
]
for argument in arguments {
if blockedExact.contains(argument) || blockedPrefixes.contains(where: { argument.hasPrefix($0) }) {
return "Advanced argument '\(argument)' is security-sensitive or destructive and is managed by Rsync 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
}
}
-29
View File
@@ -1,29 +0,0 @@
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
}
}
}
-260
View File
@@ -1,260 +0,0 @@
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 }
}
}