Files

363 lines
18 KiB
Swift
Executable File

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)"
}
}
}