363 lines
18 KiB
Swift
363 lines
18 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
enum JobEditorSection: Hashable {
|
|
case schedule
|
|
case fileWatch
|
|
}
|
|
|
|
struct JobEditorView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName()
|
|
|
|
let existingJob: SyncJob?
|
|
let initialSection: JobEditorSection?
|
|
|
|
@State private var name: String
|
|
@State private var sourcePath: String
|
|
@State private var destinationPath: String
|
|
@State private var isRemote: Bool
|
|
@State private var remoteHost: String
|
|
@State private var remoteUser: String
|
|
@State private var remotePort: Int
|
|
@State private var sshKeyPath: String
|
|
@State private var archive: Bool
|
|
@State private var compress: Bool
|
|
@State private var deleteExtraneous: Bool
|
|
@State private var preservePermissions: Bool
|
|
@State private var preserveExtendedAttributes: Bool
|
|
@State private var showProgress: Bool
|
|
@State private var exclusionsText: String
|
|
@State private var additionalArguments: String
|
|
@State private var isEnabled: Bool
|
|
@State private var scheduleEnabled: Bool
|
|
@State private var scheduleKind: SyncScheduleKind
|
|
@State private var scheduleStartAt: Date
|
|
@State private var scheduleIntervalMinutes: Int
|
|
@State private var watchEnabled: Bool
|
|
@State private var watchPath: String
|
|
@State private var watchDebounceSeconds: Int
|
|
@State private var allowUnattendedDelete: Bool
|
|
@State private var retryCount: Int
|
|
@State private var retryDelaySeconds: Int
|
|
@State private var notifyOnFailure: Bool
|
|
@State private var notifyOnSuccess: Bool
|
|
@State private var requireExpectedVolume: Bool
|
|
@State private var expectedVolumeIdentifier: String
|
|
@State private var minimumFreeSpaceMB: Int
|
|
@State private var validationMessage: String?
|
|
|
|
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
|
|
self.existingJob = job
|
|
self.initialSection = initialSection
|
|
_name = State(initialValue: job?.name ?? "New Sync Job")
|
|
_sourcePath = State(initialValue: job?.sourcePath ?? "")
|
|
_destinationPath = State(initialValue: job?.destinationPath ?? "")
|
|
_isRemote = State(initialValue: job?.isRemote ?? false)
|
|
_remoteHost = State(initialValue: job?.remoteHost ?? UserDefaults.standard.string(forKey: "defaultRemoteHost") ?? "")
|
|
_remoteUser = State(initialValue: job?.remoteUser ?? UserDefaults.standard.string(forKey: "defaultSSHUsername") ?? NSUserName())
|
|
let savedPort = UserDefaults.standard.integer(forKey: "defaultSSHPort")
|
|
_remotePort = State(initialValue: job?.remotePort ?? (savedPort == 0 ? 22 : savedPort))
|
|
_sshKeyPath = State(initialValue: job?.sshKeyPath ?? UserDefaults.standard.string(forKey: "defaultSSHKeyPath") ?? "")
|
|
_archive = State(initialValue: job?.archive ?? true)
|
|
_compress = State(initialValue: job?.compress ?? false)
|
|
_deleteExtraneous = State(initialValue: job?.deleteExtraneous ?? false)
|
|
_preservePermissions = State(initialValue: job?.preservePermissions ?? true)
|
|
_preserveExtendedAttributes = State(initialValue: job?.preserveExtendedAttributes ?? true)
|
|
_showProgress = State(initialValue: job?.showProgress ?? true)
|
|
_exclusionsText = State(initialValue: job?.exclusionsText ?? "")
|
|
_additionalArguments = State(initialValue: job?.additionalArguments ?? "")
|
|
_isEnabled = State(initialValue: job?.isEnabled ?? true)
|
|
_scheduleEnabled = State(initialValue: job?.scheduleEnabled ?? false)
|
|
_scheduleKind = State(initialValue: job?.scheduleKind ?? .daily)
|
|
_scheduleStartAt = State(initialValue: job?.scheduleStartAt ?? Date().addingTimeInterval(3600))
|
|
_scheduleIntervalMinutes = State(initialValue: job?.scheduleIntervalMinutes ?? 30)
|
|
_watchEnabled = State(initialValue: job?.watchEnabled ?? false)
|
|
_watchPath = State(initialValue: job?.watchPath ?? "")
|
|
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
|
|
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
|
|
_allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false)
|
|
_retryCount = State(initialValue: job?.retryCount ?? 2)
|
|
_retryDelaySeconds = State(initialValue: job?.retryDelaySeconds ?? 10)
|
|
_notifyOnFailure = State(initialValue: job?.notifyOnFailure ?? true)
|
|
_notifyOnSuccess = State(initialValue: job?.notifyOnSuccess ?? false)
|
|
_requireExpectedVolume = State(initialValue: job?.requireExpectedVolume ?? false)
|
|
_expectedVolumeIdentifier = State(initialValue: job?.expectedVolumeIdentifier ?? "")
|
|
_minimumFreeSpaceMB = State(initialValue: job?.minimumFreeSpaceMB ?? 512)
|
|
_validationMessage = State(initialValue: nil)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollViewReader { proxy in
|
|
Form {
|
|
Section("Job") {
|
|
TextField("Name", text: $name)
|
|
Toggle("Enabled", isOn: $isEnabled)
|
|
}
|
|
|
|
Section("Locations") {
|
|
PathField(title: "Source", path: $sourcePath)
|
|
Picker("Destination type", selection: $isRemote) {
|
|
Text("Local / Mounted Volume").tag(false)
|
|
Text("SSH / Remote Mac or Server").tag(true)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
if isRemote {
|
|
TextField("Remote path", text: $destinationPath, prompt: Text("/home/mag/backup or ~/backup"))
|
|
.textFieldStyle(.roundedBorder)
|
|
} else {
|
|
PathField(title: "Destination", path: $destinationPath)
|
|
}
|
|
}
|
|
|
|
if !isRemote {
|
|
Section("Destination Safety") {
|
|
Stepper("Minimum free space: \(minimumFreeSpaceMB) MB", value: $minimumFreeSpaceMB, in: 0...1_048_576, step: 256)
|
|
Toggle("Require this exact destination volume", isOn: $requireExpectedVolume)
|
|
if requireExpectedVolume {
|
|
HStack {
|
|
Text(expectedVolumeIdentifier.isEmpty ? "No volume captured" : expectedVolumeIdentifier)
|
|
.font(.caption.monospaced())
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer()
|
|
Button("Capture Current Volume") {
|
|
let expanded = (destinationPath as NSString).expandingTildeInPath
|
|
if FileManager.default.fileExists(atPath: expanded), let info = SyncVolumeInspector.info(forPath: expanded) {
|
|
expectedVolumeIdentifier = info.identifier
|
|
} else {
|
|
validationMessage = "Mount and select the actual destination volume before capturing its identity."
|
|
}
|
|
}
|
|
}
|
|
Text("Prevents a missing external drive from being replaced by an unintended local folder with the same path.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
if isRemote {
|
|
Section("SSH") {
|
|
TextField("Host", text: $remoteHost, prompt: Text("pegasus.local"))
|
|
TextField("User", text: $remoteUser, prompt: Text(defaultSSHUsername.isEmpty ? NSUserName() : defaultSSHUsername))
|
|
Stepper("Port: \(remotePort)", value: $remotePort, in: 1...65535)
|
|
HStack {
|
|
TextField("Private SSH key", text: $sshKeyPath, prompt: Text("~/.ssh/id_ed25519 or ~/.ssh/id_rsa"))
|
|
Button("Choose…") {
|
|
if let selected = FilePicker.chooseFile() { sshKeyPath = selected }
|
|
}
|
|
}
|
|
if sshKeyPath.hasSuffix(".pub") {
|
|
Label("Select the private key, not the .pub public key.", systemImage: "exclamationmark.triangle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Toggle("Run this job on a schedule", isOn: $scheduleEnabled)
|
|
if scheduleEnabled {
|
|
Picker("Frequency", selection: $scheduleKind) {
|
|
ForEach(SyncScheduleKind.allCases.filter { $0 != .manual }) { kind in
|
|
Label(kind.title, systemImage: kind.systemImage).tag(kind)
|
|
}
|
|
}
|
|
|
|
DatePicker(
|
|
scheduleKind == .once ? "Run date and time" : "Starting",
|
|
selection: $scheduleStartAt,
|
|
displayedComponents: scheduleKind == .once ? [.date, .hourAndMinute] : [.date, .hourAndMinute]
|
|
)
|
|
|
|
if scheduleKind == .custom {
|
|
Stepper("Every \(scheduleIntervalMinutes) minutes", value: $scheduleIntervalMinutes, in: 1...525600)
|
|
}
|
|
|
|
Label(schedulePreview, systemImage: "calendar.badge.clock")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} header: {
|
|
Text("Schedule")
|
|
} footer: {
|
|
Text("Scheduled jobs run silently and are recorded in History. They continue while Rsync Studio is running, even when its window is in the background.")
|
|
}
|
|
.id(JobEditorSection.schedule)
|
|
|
|
Section {
|
|
Toggle("Watch for file changes", isOn: $watchEnabled)
|
|
if watchEnabled {
|
|
HStack {
|
|
TextField("Watch path", text: $watchPath, prompt: Text(sourcePath.isEmpty ? "Uses the job source" : sourcePath))
|
|
Menu("Choose…") {
|
|
Button("Choose Folder…") {
|
|
if let selected = FilePicker.chooseDirectory() { watchPath = selected }
|
|
}
|
|
Button("Choose File…") {
|
|
if let selected = FilePicker.chooseFile() { watchPath = selected }
|
|
}
|
|
}
|
|
}
|
|
Stepper("Wait \(watchDebounceSeconds) seconds after changes", value: $watchDebounceSeconds, in: 1...60)
|
|
Label("Changes are monitored with macOS FSEvents. Bursts of edits are combined into one sync.", systemImage: "waveform.path.ecg")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} header: {
|
|
Text("File Watching")
|
|
} footer: {
|
|
Text("Leave the watch path empty to watch the source. If the source is a single file, only that file is synced; if it is a folder, the configured folder job runs when anything inside changes.")
|
|
}
|
|
.id(JobEditorSection.fileWatch)
|
|
|
|
Section("Reliability & Notifications") {
|
|
Stepper("Automatic retries: \(retryCount)", value: $retryCount, in: 0...10)
|
|
Stepper("Initial retry delay: \(retryDelaySeconds) seconds", value: $retryDelaySeconds, in: 1...300)
|
|
Text("Retries apply to scheduled and file-watch runs and use an increasing delay between attempts.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Toggle("Notify when a run fails", isOn: $notifyOnFailure)
|
|
Toggle("Notify when a run succeeds", isOn: $notifyOnSuccess)
|
|
}
|
|
|
|
Section("Rsync Options") {
|
|
Toggle("Archive mode (-a)", isOn: $archive)
|
|
Toggle("Compress during transfer (-z)", isOn: $compress)
|
|
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
|
|
if deleteExtraneous && (scheduleEnabled || watchEnabled) {
|
|
Toggle("Allow unattended delete", isOn: $allowUnattendedDelete)
|
|
Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Toggle("Preserve permissions", isOn: $preservePermissions)
|
|
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
|
|
Toggle("Show transfer progress", isOn: $showProgress)
|
|
}
|
|
|
|
Section("Exclusions") {
|
|
TextEditor(text: $exclusionsText)
|
|
.font(.system(.body, design: .monospaced))
|
|
.frame(minHeight: 90)
|
|
Text("One exclusion pattern per line.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Section("Advanced") {
|
|
TextField("Additional rsync arguments", text: $additionalArguments)
|
|
.font(.system(.body, design: .monospaced))
|
|
Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.navigationTitle(existingJob == nil ? "New Sync Job" : "Edit Sync Job")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") { save() }
|
|
.buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(!canSave)
|
|
}
|
|
}
|
|
.task {
|
|
guard let initialSection else { return }
|
|
try? await Task.sleep(for: .milliseconds(120))
|
|
withAnimation { proxy.scrollTo(initialSection, anchor: .top) }
|
|
}
|
|
}
|
|
}
|
|
.alert("Invalid Sync Job", isPresented: Binding(
|
|
get: { validationMessage != nil },
|
|
set: { if !$0 { validationMessage = nil } }
|
|
)) {
|
|
Button("OK", role: .cancel) { validationMessage = nil }
|
|
} message: {
|
|
Text(validationMessage ?? "The job configuration is invalid.")
|
|
}
|
|
.frame(width: 690, height: 820)
|
|
}
|
|
|
|
private var canSave: Bool {
|
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
!sourcePath.isEmpty &&
|
|
!destinationPath.isEmpty &&
|
|
(!isRemote || !remoteHost.isEmpty)
|
|
}
|
|
|
|
private var schedulePreview: String {
|
|
let previewJob = SyncJob()
|
|
previewJob.scheduleEnabled = scheduleEnabled
|
|
previewJob.scheduleKind = scheduleKind
|
|
previewJob.scheduleStartAt = scheduleStartAt
|
|
previewJob.scheduleIntervalMinutes = scheduleIntervalMinutes
|
|
if let next = previewJob.calculateNextRun() {
|
|
return "Next run: \(next.formatted(date: .abbreviated, time: .shortened))"
|
|
}
|
|
return "No future run is currently scheduled."
|
|
}
|
|
|
|
private func save() {
|
|
let job = existingJob ?? SyncJob()
|
|
job.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
job.sourcePath = sourcePath
|
|
job.destinationPath = destinationPath
|
|
job.isRemote = isRemote
|
|
job.remoteHost = remoteHost
|
|
job.remoteUser = remoteUser
|
|
job.remotePort = remotePort
|
|
job.sshKeyPath = sshKeyPath
|
|
job.archive = archive
|
|
job.compress = compress
|
|
job.deleteExtraneous = deleteExtraneous
|
|
job.preservePermissions = preservePermissions
|
|
job.preserveExtendedAttributes = preserveExtendedAttributes
|
|
job.showProgress = showProgress
|
|
job.exclusionsText = exclusionsText
|
|
job.additionalArguments = additionalArguments
|
|
job.isEnabled = isEnabled
|
|
job.scheduleEnabled = scheduleEnabled
|
|
job.scheduleKind = scheduleEnabled ? scheduleKind : .manual
|
|
job.scheduleStartAt = scheduleStartAt
|
|
job.scheduleIntervalMinutes = scheduleIntervalMinutes
|
|
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
|
|
job.watchEnabled = watchEnabled
|
|
job.watchPath = watchPath
|
|
job.watchDebounceSeconds = watchDebounceSeconds
|
|
job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false
|
|
job.retryCount = retryCount
|
|
job.retryDelaySeconds = retryDelaySeconds
|
|
job.notifyOnFailure = notifyOnFailure
|
|
job.notifyOnSuccess = notifyOnSuccess
|
|
job.requireExpectedVolume = !isRemote && requireExpectedVolume
|
|
job.expectedVolumeIdentifier = !isRemote && requireExpectedVolume ? expectedVolumeIdentifier : ""
|
|
job.minimumFreeSpaceMB = minimumFreeSpaceMB
|
|
job.updatedAt = Date()
|
|
|
|
let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual
|
|
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) {
|
|
validationMessage = message
|
|
return
|
|
}
|
|
|
|
do {
|
|
if existingJob == nil { modelContext.insert(job) }
|
|
try modelContext.save()
|
|
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
|
dismiss()
|
|
} catch {
|
|
validationMessage = "Could not save the job: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|