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 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) _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("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 Manager 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("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.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)" } } }