paddel setup new verifier
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user