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,36 +0,0 @@
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let width = proposal.width ?? .infinity
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > width, x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: width.isFinite ? width : x, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX, x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -1,22 +0,0 @@
import SwiftUI
struct PathField: View {
let title: String
@Binding var path: String
var canBrowse: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.headline)
HStack {
TextField(title, text: $path)
.textFieldStyle(.roundedBorder)
if canBrowse {
Button("Choose…") {
if let selected = FilePicker.chooseDirectory() { path = selected }
}
}
}
}
}
}
@@ -1,49 +0,0 @@
import SwiftUI
struct SplashView: View {
@State private var animate = false
var body: some View {
ZStack {
LinearGradient(
colors: [Color.accentColor.opacity(0.95), Color.accentColor.opacity(0.62), Color(nsColor: .windowBackgroundColor)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 22) {
ZStack {
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(.ultraThinMaterial)
.frame(width: 126, height: 126)
.shadow(color: .black.opacity(0.18), radius: 28, y: 14)
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 58, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white)
.rotationEffect(.degrees(animate ? 360 : 0))
}
VStack(spacing: 6) {
Text("Rsync Studio")
.font(.system(size: 32, weight: .bold, design: .rounded))
Text("Fast, dependable file synchronisation for macOS")
.font(.headline)
.foregroundStyle(.secondary)
}
ProgressView()
.controlSize(.small)
.padding(.top, 2)
}
.padding(48)
}
.onAppear {
withAnimation(.linear(duration: 1.25).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
@@ -1,25 +0,0 @@
import SwiftUI
struct StatusBadge: View {
let run: SyncRun
var body: some View {
Label(text, systemImage: icon)
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: Capsule())
}
private var text: String {
if run.isRunning { return "Running" }
if run.wasCancelled { return "Cancelled" }
return run.succeeded ? "Success" : "Failed"
}
private var icon: String {
if run.isRunning { return "arrow.triangle.2.circlepath" }
if run.wasCancelled { return "stop.circle" }
return run.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill"
}
}
-220
View File
@@ -1,220 +0,0 @@
import SwiftUI
struct HelpView: View {
@Environment(\.dismiss) private var dismiss
@State private var blocks: [HelpBlock] = [.paragraph("Loading help…")]
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "questionmark.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 1) {
Text("Rsync Studio Help")
.font(.headline)
Text("Instructions, examples and troubleshooting")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.cancelAction)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(.bar)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
blockView(block)
}
}
.textSelection(.enabled)
.frame(maxWidth: 900, alignment: .leading)
.padding(.horizontal, 34)
.padding(.vertical, 28)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.frame(minWidth: 780, idealWidth: 940, minHeight: 640, idealHeight: 780)
.task { loadHelp() }
}
@ViewBuilder
private func blockView(_ block: HelpBlock) -> some View {
switch block {
case .heading(let level, let text):
Text(text)
.font(headingFont(level))
.padding(.top, level == 1 ? 4 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
case .paragraph(let text):
Text(markdown(text))
.font(.body)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
case .bullet(let text):
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("•")
.font(.body.bold())
Text(markdown(text))
.font(.body)
.lineSpacing(3)
}
.padding(.leading, 6)
case .numbered(let number, let text):
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("\(number).")
.font(.body.weight(.semibold))
.frame(width: 28, alignment: .trailing)
Text(markdown(text))
.font(.body)
.lineSpacing(3)
}
case .code(let text):
ScrollView(.horizontal) {
Text(text)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
.background(Color(nsColor: .textBackgroundColor).opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(.quaternary, lineWidth: 1)
}
}
}
private func headingFont(_ level: Int) -> Font {
switch level {
case 1: return .largeTitle.bold()
case 2: return .title2.bold()
case 3: return .title3.weight(.semibold)
default: return .headline
}
}
private func markdown(_ text: String) -> AttributedString {
(try? AttributedString(
markdown: text,
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)) ?? AttributedString(text)
}
private func loadHelp() {
guard let url = Bundle.main.url(forResource: "Rsync Studio Help", withExtension: "md"),
let text = try? String(contentsOf: url, encoding: .utf8) else {
blocks = [.heading(1, "Help unavailable"), .paragraph("The bundled help document could not be loaded.")]
return
}
blocks = HelpMarkdownParser.parse(text)
}
}
private enum HelpBlock {
case heading(Int, String)
case paragraph(String)
case bullet(String)
case numbered(Int, String)
case code(String)
}
private enum HelpMarkdownParser {
static func parse(_ source: String) -> [HelpBlock] {
var result: [HelpBlock] = []
var paragraph: [String] = []
var codeLines: [String] = []
var insideCode = false
func flushParagraph() {
guard !paragraph.isEmpty else { return }
result.append(.paragraph(paragraph.joined(separator: " ")))
paragraph.removeAll(keepingCapacity: true)
}
func flushCode() {
guard !codeLines.isEmpty else { return }
result.append(.code(codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
}
for rawLine in source.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("```") {
if insideCode {
flushCode()
insideCode = false
} else {
flushParagraph()
insideCode = true
}
continue
}
if insideCode {
codeLines.append(rawLine)
continue
}
if line.isEmpty {
flushParagraph()
continue
}
if let heading = heading(from: line) {
flushParagraph()
result.append(.heading(heading.level, heading.text))
continue
}
if line.hasPrefix("- ") || line.hasPrefix("* ") {
flushParagraph()
result.append(.bullet(String(line.dropFirst(2))))
continue
}
if let numbered = numberedItem(from: line) {
flushParagraph()
result.append(.numbered(numbered.number, numbered.text))
continue
}
paragraph.append(line)
}
flushParagraph()
flushCode()
return result
}
private static func heading(from line: String) -> (level: Int, text: String)? {
let hashes = line.prefix { $0 == "#" }.count
guard hashes > 0, hashes <= 6 else { return nil }
let remainder = line.dropFirst(hashes)
guard remainder.first == " " else { return nil }
return (hashes, String(remainder.dropFirst()))
}
private static func numberedItem(from line: String) -> (number: Int, text: String)? {
guard let dot = line.firstIndex(of: ".") else { return nil }
let numberText = line[..<dot]
guard let number = Int(numberText) else { return nil }
let afterDot = line.index(after: dot)
guard afterDot < line.endIndex, line[afterDot] == " " else { return nil }
let textStart = line.index(after: afterDot)
return (number, String(line[textStart...]))
}
}
-772
View File
@@ -1,772 +0,0 @@
import SwiftUI
import SwiftData
import AppKit
struct JobDetailView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject private var runner: RsyncRunner
let job: SyncJob
@State private var showingEditor = false
@State private var editorSection: JobEditorSection?
@State private var errorMessage: String?
@State private var showingDeleteWarning = false
@State private var showingRunSheet = false
@State private var presentedRun: SyncRun?
@State private var preflightReport: JobPreflightReport?
@State private var isCheckingHealth = false
var body: some View {
VStack(spacing: 0) {
jobHeader
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
healthSection
locationsSection
automationSection
optionsSection
if job.deleteExtraneous {
Label("Delete mode is enabled. Destination files that are not present at the source can be removed.", systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
HStack(spacing: 10) {
Button {
runPreflight()
} label: {
if isCheckingHealth { ProgressView().controlSize(.small) }
else { Label("Test Job", systemImage: "stethoscope") }
}
.disabled(isCheckingHealth || runner.isRunning)
Button {
launch(dryRun: true, presentation: .manual)
} label: {
Label("Dry Run", systemImage: "doc.text.magnifyingglass")
}
.disabled(runner.isRunning || !job.isEnabled)
Button {
if job.deleteExtraneous {
showingDeleteWarning = true
} else {
launch(dryRun: false, presentation: .manual)
}
} label: {
Label("Run Now", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(runner.isRunning || !job.isEnabled)
if runner.isRunning && runner.currentJobID == job.id {
AnimatedSyncIndicator(size: 18, lineWidth: 2.6)
.help("Synchronising this job")
}
Spacer()
}
}
.padding(24)
.frame(maxWidth: 1050, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.sheet(isPresented: $showingEditor) {
JobEditorView(job: job, initialSection: editorSection)
}
.sheet(isPresented: $showingRunSheet) {
if let presentedRun {
RunProgressSheet(run: presentedRun, job: job)
.environmentObject(runner)
}
}
.alert("Run with --delete?", isPresented: $showingDeleteWarning) {
Button("Cancel", role: .cancel) {}
Button("Run", role: .destructive) {
launch(dryRun: false, presentation: .manual)
}
} message: {
Text("This job may delete files from the destination. Consider running a Dry Run first.")
}
.alert("Rsync Error", isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK", role: .cancel) { errorMessage = nil }
} message: {
Text(errorMessage ?? "Unknown error")
}
}
private var jobHeader: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(job.name)
.font(.title2.bold())
.lineLimit(1)
.truncationMode(.tail)
if !job.isEnabled {
Text("Disabled")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.orange.opacity(0.10), in: Capsule())
}
}
Label(job.isRemote ? "SSH remote sync" : "Local sync", systemImage: job.isRemote ? "network" : "internaldrive")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer(minLength: 16)
Button {
openEditor(nil)
} label: {
Label("Edit Job", systemImage: "pencil")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut("e", modifiers: [.command])
}
.padding(.horizontal, 24)
.frame(height: 86)
.background(Color(nsColor: .controlBackgroundColor))
}
private var healthSection: some View {
GroupBox("Job Health") {
HStack(spacing: 12) {
Image(systemName: healthIcon)
.font(.title3)
.foregroundStyle(healthColour)
VStack(alignment: .leading, spacing: 3) {
Text(preflightReport?.title ?? "Not checked")
.font(.body.weight(.semibold))
Text(healthDetail)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Run Preflight") { runPreflight() }
.buttonStyle(.bordered)
.disabled(isCheckingHealth || runner.isRunning)
}
.padding(.vertical, 3)
if let report = preflightReport {
Divider().padding(.vertical, 6)
VStack(alignment: .leading, spacing: 10) {
ForEach(report.checks) { check in
HStack(alignment: .top, spacing: 10) {
Image(systemName: check.severity == .error ? "xmark.circle.fill" : check.severity == .warning ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(check.severity == .error ? Color.red : check.severity == .warning ? Color.orange : Color.green)
.frame(width: 20, height: 20, alignment: .top)
VStack(alignment: .leading, spacing: 2) {
Text(check.title)
.font(.caption.weight(.semibold))
Text(check.detail)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private var healthIcon: String {
guard let report = preflightReport else { return "questionmark.circle" }
return report.passed ? "checkmark.shield.fill" : "exclamationmark.shield.fill"
}
private var healthColour: Color {
guard let report = preflightReport else { return .secondary }
return report.passed ? .green : .orange
}
private var healthDetail: String {
guard let report = preflightReport else { return "Run a preflight check before relying on this job unattended." }
return report.passed ? "Source, destination, rsync and connection checks passed." : "One or more checks need attention before running."
}
private func runPreflight() {
isCheckingHealth = true
Task {
let report = await runner.preflight(job: job)
preflightReport = report
isCheckingHealth = false
}
}
private var locationsSection: some View {
GroupBox("Locations") {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) {
GridRow {
Text("Source").foregroundStyle(.secondary)
Text(job.sourcePath).textSelection(.enabled)
}
GridRow {
Text("Destination").foregroundStyle(.secondary)
Text(job.destinationDisplay).textSelection(.enabled)
}
if job.isRemote {
GridRow {
Text("SSH").foregroundStyle(.secondary)
Text("Port \(job.remotePort)")
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 3)
}
}
private var automationSection: some View {
GroupBox("Automation") {
VStack(spacing: 0) {
automationRow(
title: "Schedule",
subtitle: job.scheduleEnabled ? job.scheduleDescription : "Manual only",
icon: "calendar.badge.clock",
colour: .blue,
isOn: scheduleBinding,
buttonTitle: job.scheduleEnabled ? "Edit" : "Configure"
) {
openEditor(.schedule)
}
Divider().padding(.leading, 46)
automationRow(
title: "File Watch",
subtitle: watchSubtitle,
icon: "eye.fill",
colour: .purple,
isOn: watchBinding,
buttonTitle: job.watchEnabled ? "Edit" : "Configure"
) {
openEditor(.fileWatch)
}
}
.padding(.vertical, 2)
}
}
private func automationRow(
title: String,
subtitle: String,
icon: String,
colour: Color,
isOn: Binding<Bool>,
buttonTitle: String,
action: @escaping () -> Void
) -> some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(colour.opacity(0.12))
Image(systemName: icon)
.foregroundStyle(colour)
}
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.body.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 12)
Toggle("", isOn: isOn)
.labelsHidden()
.toggleStyle(.switch)
Button(buttonTitle, action: action)
.buttonStyle(.bordered)
.controlSize(.small)
.frame(width: 88)
}
.padding(.vertical, 10)
}
private var watchSubtitle: String {
guard job.watchEnabled else { return "Off" }
let path = job.effectiveWatchPath
return "Watching \(path) · \(job.watchDebounceSeconds)s debounce"
}
private var optionsSection: some View {
GroupBox("Options") {
FlowLayout(spacing: 8) {
option("Archive", job.archive)
option("Compress", job.compress)
option("Delete", job.deleteExtraneous)
option("Permissions", job.preservePermissions)
option("Extended Attributes", job.preserveExtendedAttributes)
option("Progress", job.showProgress)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 3)
}
}
private var scheduleBinding: Binding<Bool> {
Binding(
get: { job.scheduleEnabled },
set: { enabled in
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling."
return
}
job.scheduleEnabled = enabled
if enabled {
if job.scheduleKind == .manual { job.scheduleKind = .daily }
if job.scheduleStartAt < Date() { job.scheduleStartAt = Date().addingTimeInterval(3600) }
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
} else {
job.nextRunAt = nil
}
persistAutomationChange()
}
)
}
private var watchBinding: Binding<Bool> {
Binding(
get: { job.watchEnabled },
set: { enabled in
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching."
return
}
job.watchEnabled = enabled
if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
job.watchPath = job.sourcePath
}
persistAutomationChange()
}
)
}
private func openEditor(_ section: JobEditorSection?) {
editorSection = section
showingEditor = true
}
private func persistAutomationChange() {
job.updatedAt = Date()
do {
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
} catch {
errorMessage = "Could not save automation settings: \(error.localizedDescription)"
}
}
@ViewBuilder
private func option(_ name: String, _ enabled: Bool) -> some View {
if enabled {
Label(name, systemImage: "checkmark")
.font(.caption.weight(.medium))
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(.quaternary, in: Capsule())
}
}
private enum RunPresentation {
case manual
case scheduled
var showsModal: Bool {
switch self {
case .manual: return true
case .scheduled: return false
}
}
}
private func launch(dryRun: Bool, presentation: RunPresentation) {
let executable = RsyncLocator.locate()
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable)
let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual)
modelContext.insert(run)
do {
try modelContext.save()
} catch {
errorMessage = "Could not create run history: \(error.localizedDescription)"
return
}
if presentation.showsModal {
presentedRun = run
showingRunSheet = true
}
Task {
do {
let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual)
run.output = SyncRun.sanitizedLog(result.stdout)
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
run.exitCode = Int(result.exitCode)
run.wasCancelled = result.wasCancelled
run.finishedAt = Date()
job.lastRunAt = run.finishedAt
job.lastExitCode = Int(result.exitCode)
job.updatedAt = Date()
do { try modelContext.save() }
catch { errorMessage = "Could not save run history: \(error.localizedDescription)" }
} catch {
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
run.exitCode = -1
run.finishedAt = Date()
job.lastRunAt = run.finishedAt
job.lastExitCode = -1
do { try modelContext.save() }
catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" }
if !presentation.showsModal {
errorMessage = error.localizedDescription
}
}
}
}
}
private struct RunProgressSheet: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var runner: RsyncRunner
@Bindable var run: SyncRun
let job: SyncJob
@State private var exportError: String?
@State private var showTechnicalDetails = false
@State private var now = Date()
private var isRunning: Bool { run.finishedAt == nil }
var body: some View {
VStack(spacing: 0) {
header
Divider()
Group {
if isRunning { runningContent } else { completedContent }
}
}
.frame(width: 680)
.frame(minHeight: 520)
.background(Color(nsColor: .windowBackgroundColor))
.interactiveDismissDisabled(isRunning)
.task {
while isRunning {
try? await Task.sleep(for: .seconds(1))
now = Date()
}
}
.alert("Could Not Export Log", isPresented: Binding(
get: { exportError != nil },
set: { if !$0 { exportError = nil } }
)) {
Button("OK", role: .cancel) { exportError = nil }
} message: {
Text(exportError ?? "Unknown error")
}
}
private var header: some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.accentColor.opacity(0.12))
.frame(width: 44, height: 44)
Image(systemName: run.wasDryRun ? "doc.text.magnifyingglass" : "arrow.triangle.2.circlepath")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.accentColor)
}
VStack(alignment: .leading, spacing: 3) {
Text(run.wasDryRun ? "Dry Run" : "Synchronisation")
.font(.title2.bold())
Text(run.jobName)
.foregroundStyle(.secondary)
}
Spacer()
if !isRunning {
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
}
.padding(22)
.background(.bar)
}
private var runningContent: some View {
VStack(spacing: 26) {
Spacer(minLength: 26)
AnimatedSyncIndicator(size: 124, lineWidth: 9)
.padding(.bottom, 2)
VStack(spacing: 8) {
Text(run.wasDryRun ? "Checking what would change…" : "Synchronising files…")
.font(.title3.bold())
Text(run.wasDryRun
? "Rsync is comparing the source and destination. No files will be changed."
: "Rsync is scanning and transferring changed files. Full output is being captured without slowing the interface.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 500)
}
connectionCard
HStack(spacing: 22) {
Label("Elapsed \(elapsedText)", systemImage: "timer")
Label("Log captured", systemImage: "doc.text")
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Spacer()
HStack {
Text("You can continue using the app while this transfer runs.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if runner.currentJobID == run.jobID {
Button("Cancel", role: .destructive) { runner.cancel() }
.buttonStyle(.bordered)
.controlSize(.large)
}
}
.padding(22)
.background(.bar)
}
}
private var connectionCard: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 5) {
Label("Source", systemImage: "folder.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(job.sourcePath)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "arrow.right")
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 5) {
Label("Destination", systemImage: job.isRemote ? "server.rack" : "externaldrive.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(job.destinationDisplay)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, 28)
}
private var completedContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
completionHero
if run.summary.hasStatistics {
HStack(spacing: 12) {
metricCard(title: run.wasDryRun ? "Would transfer" : "Transferred", value: run.summary.numberOfFilesTransferred ?? "—", icon: "doc.on.doc.fill")
metricCard(title: "Data", value: run.summary.totalTransferredFileSize ?? "—", icon: "arrow.down.circle.fill")
metricCard(title: "Files checked", value: run.summary.numberOfFiles ?? "—", icon: "checklist")
}
GroupBox("Transfer Summary") {
Grid(alignment: .leading, horizontalSpacing: 34, verticalSpacing: 10) {
summaryRow("Files checked", run.summary.numberOfFiles)
summaryRow(run.wasDryRun ? "Files that would transfer" : "Files transferred", run.summary.numberOfFilesTransferred)
summaryRow("Total source size", run.summary.totalFileSize)
summaryRow(run.wasDryRun ? "Data that would transfer" : "Data transferred", run.summary.totalTransferredFileSize)
summaryRow("Unmatched data", run.summary.unmatchedData)
summaryRow("Matched data", run.summary.matchedData)
summaryRow("File list size", run.summary.fileListSize)
summaryRow("List generation", run.summary.fileListGenerationTime)
summaryRow("List transfer", run.summary.fileListTransferTime)
summaryRow("Sent", run.summary.totalSent)
summaryRow("Received", run.summary.totalReceived)
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
if run.summary.transferRate != nil || run.summary.totalSizeAndSpeedup != nil {
VStack(alignment: .leading, spacing: 6) {
if let transferRate = run.summary.transferRate { Text(transferRate) }
if let speedup = run.summary.totalSizeAndSpeedup { Text(speedup) }
}
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if !run.succeeded {
DisclosureGroup("Technical Details", isExpanded: $showTechnicalDetails) {
Text(run.errorOutput.isEmpty ? "Rsync exited without additional error output." : run.errorOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
.padding(14)
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
}
Divider()
HStack {
Button { exportLog() } label: {
Label("Export Log…", systemImage: "square.and.arrow.up")
}
.controlSize(.large)
Spacer()
if let duration = run.duration {
Label(Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)), systemImage: "timer")
.foregroundStyle(.secondary)
}
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
.padding(24)
}
}
private var completionHero: some View {
HStack(spacing: 16) {
Image(systemName: completionIcon)
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(completionColour)
VStack(alignment: .leading, spacing: 4) {
Text(completionTitle)
.font(.title2.bold())
Text(completionMessage)
.foregroundStyle(.secondary)
if let exitCode = run.exitCode, !run.succeeded {
Text("Rsync exit code \(exitCode)")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(completionColour.opacity(0.09), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private func metricCard(title: String, value: String, icon: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.foregroundStyle(Color.accentColor)
Text(value)
.font(.title3.bold())
.lineLimit(1)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
}
private var completionIcon: String {
if run.wasCancelled { return "xmark.circle.fill" }
return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
}
private var completionColour: Color {
if run.wasCancelled { return .secondary }
return run.succeeded ? .green : .orange
}
private var completionTitle: String {
if run.wasCancelled { return "Synchronisation Cancelled" }
if run.succeeded { return run.wasDryRun ? "Dry Run Complete" : "Synchronisation Complete" }
return "Synchronisation Failed"
}
private var completionMessage: String {
if run.wasCancelled { return "The transfer was stopped before it completed." }
if run.succeeded && run.wasDryRun { return "No files were changed. The summary below shows what a real run would transfer." }
if run.succeeded { return "Your files were synchronised successfully." }
if !run.errorOutput.isEmpty {
return run.errorOutput.components(separatedBy: .newlines).first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) ?? "Rsync reported an error."
}
return "Rsync could not complete this synchronisation."
}
private var elapsedText: String {
let interval = max(0, now.timeIntervalSince(run.startedAt))
return Duration.seconds(interval).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated))
}
@ViewBuilder
private func summaryRow(_ title: String, _ value: String?) -> some View {
if let value {
GridRow {
Text(title).foregroundStyle(.secondary)
Text(value).fontWeight(.medium).textSelection(.enabled)
}
}
}
private func exportLog() {
let panel = NSSavePanel()
panel.title = "Export Rsync Log"
panel.prompt = "Export"
panel.canCreateDirectories = true
panel.nameFieldStringValue = defaultLogFilename
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try run.exportText.write(to: url, atomically: true, encoding: .utf8) }
catch { exportError = error.localizedDescription }
}
private var defaultLogFilename: String {
let safeName = run.jobName.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-")
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
return "\(safeName)-\(formatter.string(from: run.startedAt)).log"
}
}
-362
View File
@@ -1,362 +0,0 @@
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)"
}
}
}
-523
View File
@@ -1,523 +0,0 @@
import SwiftUI
import SwiftData
import AppKit
import UniformTypeIdentifiers
struct JobListView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@Binding var selection: SyncJob?
@Binding var showingNewJob: Bool
@State private var searchText = ""
@State private var filter: JobFilter = .all
@State private var persistenceError: String?
@State private var transferMessage: String?
var body: some View {
VStack(spacing: 0) {
dashboardHeader
Picker("Filter", selection: $filter) {
ForEach(JobFilter.allCases) { item in
Text(item.title).tag(item)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 14)
.padding(.bottom, 10)
Divider()
if filteredJobs.isEmpty {
emptyState
} else {
List(selection: $selection) {
ForEach(filteredJobs) { job in
JobRow(job: job, isRunning: automationActivityMatches(job))
.tag(job)
.listRowInsets(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10))
.contextMenu {
Button("Open Job") { selection = job }
Button("Duplicate") { duplicate(job) }
Divider()
Button(job.isEnabled ? "Disable" : "Enable") {
job.isEnabled.toggle()
job.updatedAt = Date()
saveAndRefresh()
}
Button("Delete", role: .destructive) { delete(job) }
}
}
}
.listStyle(.sidebar)
}
}
.alert("Database Error", isPresented: Binding(
get: { persistenceError != nil },
set: { if !$0 { persistenceError = nil } }
)) {
Button("OK", role: .cancel) { persistenceError = nil }
} message: {
Text(persistenceError ?? "Unable to save job changes.")
}
.alert("Job Configuration", isPresented: Binding(
get: { transferMessage != nil },
set: { if !$0 { transferMessage = nil } }
)) {
Button("OK", role: .cancel) { transferMessage = nil }
} message: {
Text(transferMessage ?? "")
}
}
private var dashboardHeader: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Jobs")
.font(.headline)
Text("\(jobs.count) configured job\(jobs.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
statusPill
Menu {
Button("Import Jobs…", systemImage: "square.and.arrow.down") { importJobs() }
Button("Export Jobs…", systemImage: "square.and.arrow.up") { exportJobs() }
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.help("Import or export job configurations")
Button {
showingNewJob = true
} label: {
Image(systemName: "plus")
}
.buttonStyle(.borderedProminent)
.help("New Sync Job")
}
HStack(spacing: 8) {
TextField("Search jobs", text: $searchText)
.textFieldStyle(.roundedBorder)
if automation.isSyncAllRunning {
Button(role: .destructive) {
automation.cancelSyncAll()
} label: {
Label("Cancel All", systemImage: "stop.fill")
}
.buttonStyle(.bordered)
.help("Cancel the current Sync All batch")
} else {
Button {
automation.syncAllEnabledJobs()
} label: {
Label("Sync All", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.bordered)
.disabled(jobs.allSatisfy { !$0.isEnabled })
.help("Run every enabled sync job in sequence")
}
}
HStack(spacing: 9) {
metricCard(value: automation.scheduledJobCount, title: "Scheduled", icon: "calendar.badge.clock", colour: .blue)
metricCard(value: automation.watchedJobCount, title: "Watching", icon: "eye.fill", colour: .purple)
}
if automation.isSyncAllRunning {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: Double(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped), total: Double(max(1, automation.syncAllTotal)))
HStack {
Text("Sync All · \(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped) of \(automation.syncAllTotal)")
Spacer()
Text("✓ \(automation.syncAllCompleted) ! \(automation.syncAllFailed) – \(automation.syncAllSkipped)")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(14)
}
@ViewBuilder
private var statusPill: some View {
if !automation.isNetworkAvailable {
Label("Offline", systemImage: "wifi.slash")
.font(.caption.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.10), in: Capsule())
} else if automation.activity == .idle {
Label("Ready", systemImage: "checkmark.circle.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.green.opacity(0.10), in: Capsule())
} else {
Label(automation.activityDisplayTitle, systemImage: "arrow.triangle.2.circlepath")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.10), in: Capsule())
}
}
private func metricCard(value: Int, title: String, icon: String, colour: Color) -> some View {
HStack(spacing: 9) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(colour.opacity(0.11))
Image(systemName: icon)
.foregroundStyle(colour)
}
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 0) {
Text("\(value)")
.font(.headline)
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(9)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.10))
}
}
private var emptyState: some View {
ContentUnavailableView(
searchText.isEmpty ? "No Jobs" : "No Matching Jobs",
systemImage: searchText.isEmpty ? "arrow.left.arrow.right.square" : "magnifyingglass",
description: Text(searchText.isEmpty ? "Create a sync job to get started." : "Try a different search or filter.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var filteredJobs: [SyncJob] {
jobs.filter { job in
let matchesSearch = searchText.isEmpty ||
job.name.localizedCaseInsensitiveContains(searchText) ||
job.sourcePath.localizedCaseInsensitiveContains(searchText) ||
job.destinationDisplay.localizedCaseInsensitiveContains(searchText)
let matchesFilter: Bool
switch filter {
case .all: matchesFilter = true
case .scheduled: matchesFilter = job.scheduleEnabled && job.scheduleKind != .manual
case .watching: matchesFilter = job.watchEnabled
case .attention: matchesFilter = job.lastExitCode != nil && job.lastExitCode != 0
}
return matchesSearch && matchesFilter
}
}
private func automationActivityMatches(_ job: SyncJob) -> Bool {
if case .running(let name) = automation.activity { return name == job.name }
return false
}
private func duplicate(_ job: SyncJob) {
let copy = SyncJob(name: job.name + " Copy", sourcePath: job.sourcePath, destinationPath: job.destinationPath)
copy.isRemote = job.isRemote
copy.remoteHost = job.remoteHost
copy.remoteUser = job.remoteUser
copy.remotePort = job.remotePort
copy.sshKeyPath = job.sshKeyPath
copy.archive = job.archive
copy.compress = job.compress
copy.deleteExtraneous = job.deleteExtraneous
copy.preservePermissions = job.preservePermissions
copy.preserveExtendedAttributes = job.preserveExtendedAttributes
copy.showProgress = job.showProgress
copy.exclusionsText = job.exclusionsText
copy.additionalArguments = job.additionalArguments
copy.isEnabled = job.isEnabled
copy.scheduleEnabled = job.scheduleEnabled
copy.scheduleKind = job.scheduleKind
copy.scheduleStartAt = job.scheduleStartAt
copy.scheduleIntervalMinutes = job.scheduleIntervalMinutes
copy.nextRunAt = copy.calculateNextRun(after: Date().addingTimeInterval(-1))
copy.watchEnabled = job.watchEnabled
copy.watchPath = job.watchPath
copy.watchDebounceSeconds = job.watchDebounceSeconds
copy.allowUnattendedDelete = job.allowUnattendedDelete
copy.retryCount = job.retryCount
copy.retryDelaySeconds = job.retryDelaySeconds
copy.notifyOnFailure = job.notifyOnFailure
copy.notifyOnSuccess = job.notifyOnSuccess
copy.requireExpectedVolume = job.requireExpectedVolume
copy.expectedVolumeIdentifier = job.expectedVolumeIdentifier
copy.minimumFreeSpaceMB = job.minimumFreeSpaceMB
modelContext.insert(copy)
saveAndRefresh()
selection = copy
}
private func exportJobs() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "Rsync Studio-Jobs.json"
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let document = JobTransferDocument(version: 1, exportedAt: Date(), jobs: jobs.map(JobTransferJob.init))
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(document).write(to: url, options: .atomic)
transferMessage = "Exported \(jobs.count) job configuration(s). SSH private-key paths are intentionally excluded."
} catch { persistenceError = "Could not export jobs: \(error.localizedDescription)" }
}
private func importJobs() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let document = try decoder.decode(JobTransferDocument.self, from: Data(contentsOf: url))
guard document.version == 1 else { throw JobTransferError.unsupportedVersion }
var count = 0
for item in document.jobs {
let job = item.makeJob()
modelContext.insert(job)
count += 1
}
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
transferMessage = "Imported \(count) job configuration(s). Remote jobs are disabled until you select a private key or confirm SSH-agent access."
} catch { persistenceError = "Could not import jobs: \(error.localizedDescription)" }
}
private func delete(_ job: SyncJob) {
if selection?.id == job.id { selection = nil }
modelContext.delete(job)
saveAndRefresh()
}
private func saveAndRefresh() {
do {
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
} catch {
persistenceError = error.localizedDescription
}
}
}
private struct JobRow: View {
let job: SyncJob
let isRunning: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(iconColour.opacity(0.12))
Image(systemName: job.isRemote ? "server.rack" : "folder.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(iconColour)
}
.frame(width: 38, height: 38)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 7) {
Text(job.name)
.font(.body.weight(.semibold))
.lineLimit(1)
if isRunning {
AnimatedSyncIndicator(size: 15, lineWidth: 2.2)
}
Spacer()
statusIcon
}
pathSummary
HStack(spacing: 6) {
if job.scheduleEnabled && job.scheduleKind != .manual {
miniBadge(job.scheduleKind.title, icon: "calendar.badge.clock", colour: .blue)
}
if job.watchEnabled {
miniBadge("Watching", icon: "eye.fill", colour: .purple)
}
if !job.isEnabled {
miniBadge("Disabled", icon: "pause.fill", colour: .secondary)
}
}
Text(timingText)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.secondary.opacity(0.09))
}
.opacity(job.isEnabled ? 1 : 0.62)
}
private var pathSummary: some View {
HStack(spacing: 5) {
Text(shortPath(job.sourcePath))
.lineLimit(1)
.truncationMode(.middle)
Image(systemName: "arrow.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
Text(shortPath(job.destinationDisplay))
.lineLimit(1)
.truncationMode(.middle)
}
.font(.caption)
.foregroundStyle(.secondary)
}
@ViewBuilder
private var statusIcon: some View {
if isRunning {
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
.foregroundStyle(Color.accentColor)
} else if job.lastExitCode == 0 {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if job.lastExitCode != nil {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
} else {
Image(systemName: "circle.dashed")
.foregroundStyle(.tertiary)
}
}
private var timingText: String {
if let next = job.nextRunAt, job.scheduleEnabled {
return "Next run \(next.formatted(.relative(presentation: .named)))"
}
if let last = job.lastRunAt {
return "Last run \(last.formatted(.relative(presentation: .named)))"
}
return "Not run yet"
}
private var iconColour: Color {
if !job.isEnabled { return .secondary }
return job.isRemote ? .indigo : .blue
}
private func shortPath(_ path: String) -> String {
guard path.count > 42 else { return path }
return "…" + path.suffix(41)
}
private func miniBadge(_ title: String, icon: String, colour: Color) -> some View {
Label(title, systemImage: icon)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(colour)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(colour.opacity(0.10), in: Capsule())
}
}
private enum JobFilter: String, CaseIterable, Identifiable {
case all, scheduled, watching, attention
var id: String { rawValue }
var title: String {
switch self {
case .all: return "All"
case .scheduled: return "Scheduled"
case .watching: return "Watching"
case .attention: return "Issues"
}
}
}
private struct JobTransferDocument: Codable {
let version: Int
let exportedAt: Date
let jobs: [JobTransferJob]
}
private struct JobTransferJob: Codable {
let name, sourcePath, destinationPath: String
let isRemote: Bool
let remoteHost, remoteUser: String
let remotePort: Int
let archive, compress, deleteExtraneous, preservePermissions, preserveExtendedAttributes, showProgress: Bool
let exclusionsText, additionalArguments: String
let isEnabled: Bool
let scheduleEnabled: Bool
let scheduleKindRaw: String
let scheduleStartAt: Date
let scheduleIntervalMinutes: Int
let watchEnabled: Bool
let watchPath: String
let watchDebounceSeconds: Int
let allowUnattendedDelete: Bool
let retryCount, retryDelaySeconds: Int
let notifyOnFailure, notifyOnSuccess: Bool
let requireExpectedVolume: Bool
let expectedVolumeIdentifier: String
let minimumFreeSpaceMB: Int
init(_ job: SyncJob) {
name = job.name; sourcePath = job.sourcePath; destinationPath = job.destinationPath
isRemote = job.isRemote; remoteHost = job.remoteHost; remoteUser = job.remoteUser; remotePort = job.remotePort
archive = job.archive; compress = job.compress; deleteExtraneous = job.deleteExtraneous
preservePermissions = job.preservePermissions; preserveExtendedAttributes = job.preserveExtendedAttributes; showProgress = job.showProgress
exclusionsText = job.exclusionsText; additionalArguments = job.additionalArguments; isEnabled = job.isEnabled
scheduleEnabled = job.scheduleEnabled; scheduleKindRaw = job.scheduleKindRaw; scheduleStartAt = job.scheduleStartAt; scheduleIntervalMinutes = job.scheduleIntervalMinutes
watchEnabled = job.watchEnabled; watchPath = job.watchPath; watchDebounceSeconds = job.watchDebounceSeconds
allowUnattendedDelete = job.allowUnattendedDelete; retryCount = job.retryCount; retryDelaySeconds = job.retryDelaySeconds
notifyOnFailure = job.notifyOnFailure; notifyOnSuccess = job.notifyOnSuccess
requireExpectedVolume = job.requireExpectedVolume; expectedVolumeIdentifier = job.expectedVolumeIdentifier; minimumFreeSpaceMB = job.minimumFreeSpaceMB
}
func makeJob() -> SyncJob {
let job = SyncJob(name: name, sourcePath: sourcePath, destinationPath: destinationPath)
job.isRemote = isRemote; job.remoteHost = remoteHost; job.remoteUser = remoteUser; job.remotePort = remotePort; job.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 = isRemote ? false : isEnabled
job.scheduleEnabled = isRemote ? false : scheduleEnabled; job.scheduleKindRaw = scheduleKindRaw; job.scheduleStartAt = scheduleStartAt; job.scheduleIntervalMinutes = scheduleIntervalMinutes
job.nextRunAt = job.scheduleEnabled ? job.calculateNextRun(after: Date().addingTimeInterval(-1)) : nil
job.watchEnabled = isRemote ? false : watchEnabled; job.watchPath = watchPath; job.watchDebounceSeconds = watchDebounceSeconds
job.allowUnattendedDelete = allowUnattendedDelete; job.retryCount = retryCount; job.retryDelaySeconds = retryDelaySeconds
job.notifyOnFailure = notifyOnFailure; job.notifyOnSuccess = notifyOnSuccess; job.requireExpectedVolume = requireExpectedVolume
job.expectedVolumeIdentifier = expectedVolumeIdentifier; job.minimumFreeSpaceMB = minimumFreeSpaceMB
return job
}
}
private enum JobTransferError: LocalizedError {
case unsupportedVersion
var errorDescription: String? { "This job configuration file uses an unsupported schema version." }
}
@@ -1,48 +0,0 @@
import SwiftUI
import AppKit
struct LicenseRequiredView: View {
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 54, weight: .medium))
.foregroundStyle(Color.accentColor)
VStack(spacing: 7) {
Text(licensing.statusTitle)
.font(.title2.bold())
Text(licensing.statusDetail)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
HStack(spacing: 12) {
Button("Buy Rsync Studio…") { openPurchasePage() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button("Open Licence Settings") { openSettings() }
.controlSize(.large)
}
Text("Your jobs and history remain on this Mac. Rsync Studio does not delete local data when a trial expires or a licence is removed.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
.padding(48)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private func openPurchasePage() {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String,
let url = URL(string: raw),
url.scheme?.lowercased() == "https" else { return }
NSWorkspace.shared.open(url)
}
}
-145
View File
@@ -1,145 +0,0 @@
import SwiftUI
import AppKit
struct LicenseView: View {
@EnvironmentObject private var licensing: LicensingManager
@State private var licenceKey = ""
@State private var signedToken = ""
@State private var errorMessage: String?
@State private var showingTokenEntry = false
var body: some View {
Form {
Section("Licence Status") {
HStack(spacing: 12) {
Image(systemName: statusIcon)
.font(.title2)
.foregroundStyle(licensing.canUseApp ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 3) {
Text(licensing.statusTitle).font(.headline)
Text(licensing.statusDetail).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
LabeledContent("Installation ID", value: licensing.installationID)
.textSelection(.enabled)
Text("The installation ID is a random UUID stored in macOS Keychain. Rsync Studio does not use your Mac serial number or other hardware identifiers.")
.font(.caption)
.foregroundStyle(.secondary)
}
if case .licensed(let payload) = licensing.state {
Section("Current Licence") {
LabeledContent("Edition", value: payload.edition)
LabeledContent("Licence ID", value: payload.licenseID)
LabeledContent("Licensed to", value: payload.displayOwner)
if let expiresAt = payload.expiresAt {
LabeledContent("Expires", value: expiresAt.formatted(date: .long, time: .omitted))
} else {
LabeledContent("Term", value: "Perpetual")
}
Button("Deactivate This Mac…", role: .destructive) {
Task { await deactivate() }
}
.disabled(licensing.isWorking)
}
} else {
Section("Activate Rsync Studio") {
TextField("Licence key", text: $licenceKey, prompt: Text("XXXX-XXXX-XXXX-XXXX"))
.textContentType(.password)
HStack {
Button {
Task { await activate() }
} label: {
if licensing.isWorking { ProgressView().controlSize(.small) }
else { Label("Activate", systemImage: "checkmark.seal") }
}
.buttonStyle(.borderedProminent)
.disabled(licenceKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || licensing.isWorking)
Button(showingTokenEntry ? "Hide Signed Token" : "Install Signed Token…") {
showingTokenEntry.toggle()
}
}
Button("Buy Rsync Studio…") { openPurchasePage() }
Text("Buy Personal or Professional online, then enter the licence key delivered to your email. Signed offline licence tokens can also be installed without contacting the activation server.")
.font(.caption)
.foregroundStyle(.secondary)
}
if showingTokenEntry {
Section("Signed Offline Licence") {
TextEditor(text: $signedToken)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 86)
Button("Install Signed Licence") { installToken() }
.disabled(signedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
if case .invalid = licensing.state {
Section("Recovery") {
Button("Remove Invalid Local Licence") { licensing.removeInvalidLocalLicence() }
Text("This removes only the stored local licence token. Trial dates and the installation identity are not reset.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("Privacy") {
Text("Licence activation sends only the licence key, random installation ID, app version and platform to the licensing service. Sync paths, filenames, SSH hosts, credentials, job history and file contents are never included.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.alert("Rsync Studio Licence", isPresented: Binding(
get: { errorMessage != nil || licensing.lastMessage != nil },
set: { if !$0 { errorMessage = nil; licensing.lastMessage = nil } }
)) {
Button("OK", role: .cancel) { errorMessage = nil; licensing.lastMessage = nil }
} message: {
Text(errorMessage ?? licensing.lastMessage ?? "")
}
}
private var statusIcon: String {
switch licensing.state {
case .licensed: "checkmark.seal.fill"
case .trial: "clock.badge.checkmark"
case .expired: "clock.badge.exclamationmark"
case .invalid: "exclamationmark.shield.fill"
}
}
private func activate() async {
do {
try await licensing.activate(licenseKey: licenceKey)
licenceKey = ""
} catch { errorMessage = error.localizedDescription }
}
private func deactivate() async {
do { try await licensing.deactivate() }
catch { errorMessage = error.localizedDescription }
}
private func installToken() {
do {
try licensing.installSignedToken(signedToken)
signedToken = ""
showingTokenEntry = false
} catch { errorMessage = error.localizedDescription }
}
private func openPurchasePage() {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String,
let url = URL(string: raw),
url.scheme?.lowercased() == "https" else {
errorMessage = "The Rsync Studio purchase page is not configured in this build."
return
}
NSWorkspace.shared.open(url)
}
}
@@ -1,199 +0,0 @@
import SwiftUI
import SwiftData
import AppKit
import UniformTypeIdentifiers
struct RunHistoryView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@State private var selection: SyncRun?
@State private var persistenceError: String?
@State private var searchText = ""
@State private var filter: HistoryFilter = .all
let onBack: () -> Void
var body: some View {
HSplitView {
VStack(spacing: 0) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("Recent Runs")
.font(.headline)
Text("\(runs.count) recorded")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Menu {
Button("Export Selected…") { exportRuns(selection.map { [$0] } ?? []) }
.disabled(selection == nil)
Button("Export All…") { exportRuns(runs) }
.disabled(runs.isEmpty)
} label: {
Image(systemName: "square.and.arrow.up")
}
.menuStyle(.borderlessButton)
.help("Export run logs")
Button("Clear", role: .destructive) {
runs.forEach(modelContext.delete)
selection = nil
saveHistoryChanges()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(runs.isEmpty)
}
.padding(14)
VStack(spacing: 8) {
TextField("Search history", text: $searchText)
.textFieldStyle(.roundedBorder)
Picker("History filter", selection: $filter) {
ForEach(HistoryFilter.allCases) { item in Text(item.title).tag(item) }
}
.pickerStyle(.segmented)
.labelsHidden()
}
.padding(.horizontal, 14)
.padding(.bottom, 10)
Divider()
if filteredRuns.isEmpty {
ContentUnavailableView(
"No Run History",
systemImage: "clock.arrow.circlepath",
description: Text(runs.isEmpty ? "Completed, failed and background synchronisations will appear here." : "No runs match the current search or filter.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(selection: $selection) {
ForEach(filteredRuns) { run in
HStack(spacing: 11) {
Image(systemName: statusIcon(run))
.foregroundStyle(statusColour(run))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(run.jobName)
.fontWeight(.semibold)
.lineLimit(1)
Spacer()
StatusBadge(run: run)
}
Text(run.startedAt, format: .dateTime.day().month(.abbreviated).year().hour().minute())
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
Label(run.trigger.title, systemImage: run.trigger.systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(run.trigger == .watched ? Color.purple : Color.blue)
if run.wasDryRun {
Text("Dry run")
.font(.caption2.weight(.semibold))
.foregroundStyle(.blue)
}
}
}
}
.padding(.vertical, 3)
.tag(run)
.contextMenu {
Button("Delete Log", role: .destructive) {
if selection?.id == run.id { selection = nil }
modelContext.delete(run)
saveHistoryChanges()
}
}
}
}
.listStyle(.sidebar)
}
}
.frame(minWidth: 300, idealWidth: 340, maxWidth: 390)
Group {
if let selection {
RunLogView(run: selection)
} else {
ContentUnavailableView(
"Select a Run",
systemImage: "clock.arrow.circlepath",
description: Text("Select a completed or failed run to inspect its summary and full log.")
)
}
}
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.alert("Database Error", isPresented: Binding(
get: { persistenceError != nil },
set: { if !$0 { persistenceError = nil } }
)) {
Button("OK", role: .cancel) { persistenceError = nil }
} message: {
Text(persistenceError ?? "Unable to update run history.")
}
}
private var filteredRuns: [SyncRun] {
runs.filter { run in
let matchesSearch = searchText.isEmpty || run.jobName.localizedCaseInsensitiveContains(searchText) || run.output.localizedCaseInsensitiveContains(searchText) || run.errorOutput.localizedCaseInsensitiveContains(searchText)
let matchesFilter: Bool
switch filter {
case .all: matchesFilter = true
case .failed: matchesFilter = !run.isRunning && !run.succeeded && !run.wasCancelled
case .successful: matchesFilter = run.succeeded
case .automatic: matchesFilter = run.trigger == .scheduled || run.trigger == .watched
}
return matchesSearch && matchesFilter
}
}
private func exportRuns(_ items: [SyncRun]) {
guard !items.isEmpty else { return }
let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.nameFieldStringValue = items.count == 1 ? "Rsync Studio-\(items[0].jobName)-Run.txt" : "Rsync Studio-History.txt"
guard panel.runModal() == .OK, let url = panel.url else { return }
let text = items.map(\.exportText).joined(separator: "\n\n" + String(repeating: "=", count: 72) + "\n\n")
do { try text.write(to: url, atomically: true, encoding: .utf8) }
catch { persistenceError = "Could not export history: \(error.localizedDescription)" }
}
private func saveHistoryChanges() {
do { try modelContext.save() }
catch { persistenceError = error.localizedDescription }
}
private func statusIcon(_ run: SyncRun) -> String {
if run.isRunning { return "arrow.triangle.2.circlepath" }
if run.wasCancelled { return "xmark.circle.fill" }
return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
}
private func statusColour(_ run: SyncRun) -> Color {
if run.isRunning { return .blue }
if run.wasCancelled { return .secondary }
return run.succeeded ? .green : .orange
}
}
private enum HistoryFilter: String, CaseIterable, Identifiable {
case all, failed, successful, automatic
var id: String { rawValue }
var title: String {
switch self {
case .all: return "All"
case .failed: return "Failed"
case .successful: return "Success"
case .automatic: return "Automatic"
}
}
}
-47
View File
@@ -1,47 +0,0 @@
import SwiftUI
struct RunLogView: View {
let run: SyncRun
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading) {
Text(run.jobName).font(.title.bold())
Text(run.startedAt.formatted(date: .abbreviated, time: .standard))
.foregroundStyle(.secondary)
}
Spacer()
StatusBadge(run: run)
}
if let duration = run.duration {
LabeledContent("Duration", value: Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)))
}
if let exitCode = run.exitCode {
LabeledContent("Exit code", value: String(exitCode))
}
LabeledContent("Mode", value: run.wasDryRun ? "Dry Run" : "Live Run")
console(title: "Command", text: run.command)
if !run.output.isEmpty { console(title: "Output", text: run.output) }
if !run.errorOutput.isEmpty { console(title: "Errors", text: run.errorOutput) }
}
.padding(28)
}
.navigationTitle("Run Log")
}
private func console(title: String, text: String) -> some View {
GroupBox(title) {
ScrollView(.horizontal) {
Text(text)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -1,220 +0,0 @@
import SwiftUI
import SwiftData
struct ScheduleCalendarView: View {
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@State private var displayedMonth = Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date()
@State private var selectedDay = Date()
@State private var editingJob: SyncJob?
private let calendar = Calendar.current
private let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7)
var body: some View {
HStack(spacing: 0) {
VStack(spacing: 0) {
monthHeader
Divider()
calendarGrid
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
agenda
.frame(width: 320)
.background(.bar)
}
.sheet(item: $editingJob) { job in
JobEditorView(job: job)
}
}
private var monthHeader: some View {
HStack {
Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") }
.buttonStyle(.bordered)
VStack(alignment: .leading, spacing: 2) {
Text(displayedMonth.formatted(.dateTime.month(.wide).year()))
.font(.title2.bold())
Text("\(monthOccurrences.count) scheduled runs")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Today") {
displayedMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date()
selectedDay = Date()
}
.buttonStyle(.borderedProminent)
Button { moveMonth(1) } label: { Image(systemName: "chevron.right") }
.buttonStyle(.bordered)
}
.padding(18)
}
private var calendarGrid: some View {
VStack(spacing: 8) {
LazyVGrid(columns: columns, spacing: 6) {
ForEach(calendar.shortWeekdaySymbols, id: \.self) { symbol in
Text(symbol.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
ForEach(monthCells.indices, id: \.self) { index in
if let date = monthCells[index] {
dayCell(date)
} else {
Color.clear.frame(minHeight: 86)
}
}
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func dayCell(_ date: Date) -> some View {
let occurrences = occurrences(on: date)
let selected = calendar.isDate(date, inSameDayAs: selectedDay)
let today = calendar.isDateInToday(date)
return Button {
selectedDay = date
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("\(calendar.component(.day, from: date))")
.font(.body.weight(today ? .bold : .medium))
.foregroundStyle(today ? Color.accentColor : .primary)
Spacer()
if !occurrences.isEmpty {
Text("\(occurrences.count)")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.14), in: Capsule())
}
}
ForEach(Array(occurrences.prefix(2)), id: \.id) { item in
HStack(spacing: 4) {
Circle().fill(Color.accentColor).frame(width: 5, height: 5)
Text(item.job.name)
.font(.caption2)
.lineLimit(1)
Spacer()
Text(item.date.formatted(date: .omitted, time: .shortened))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
}
.padding(9)
.frame(maxWidth: .infinity, minHeight: 86, alignment: .topLeading)
.background(selected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(selected ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.12)))
}
.buttonStyle(.plain)
}
private var agenda: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 3) {
Text("Agenda").font(.title3.bold())
Text(selectedDay.formatted(date: .complete, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
let items = occurrences(on: selectedDay)
if items.isEmpty {
ContentUnavailableView(
"No Scheduled Runs",
systemImage: "calendar",
description: Text("Select another day or add a schedule to a sync job.")
)
} else {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(items, id: \.id) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: item.job.isRemote ? "server.rack" : "folder.fill")
.foregroundStyle(Color.accentColor)
Text(item.job.name).fontWeight(.semibold)
Spacer()
}
Label(item.date.formatted(date: .omitted, time: .shortened), systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
Text(item.job.destinationDisplay)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack {
Spacer()
Button("Edit Schedule") { editingJob = item.job }
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
Spacer()
}
.padding(18)
}
private var monthInterval: DateInterval {
calendar.dateInterval(of: .month, for: displayedMonth) ?? DateInterval(start: displayedMonth, duration: 2_592_000)
}
private var monthCells: [Date?] {
let interval = monthInterval
let days = calendar.range(of: .day, in: .month, for: interval.start) ?? 1..<2
let weekday = calendar.component(.weekday, from: interval.start)
let leading = (weekday - calendar.firstWeekday + 7) % 7
var cells = Array<Date?>(repeating: nil, count: leading)
for day in days {
if let date = calendar.date(byAdding: .day, value: day - 1, to: interval.start) { cells.append(date) }
}
while cells.count % 7 != 0 { cells.append(nil) }
return cells
}
private var monthOccurrences: [CalendarOccurrence] {
scheduledJobs.flatMap { job in
job.occurrences(from: monthInterval.start, through: monthInterval.end).map { CalendarOccurrence(job: job, date: $0) }
}.sorted { $0.date < $1.date }
}
private var scheduledJobs: [SyncJob] {
jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }
}
private func occurrences(on day: Date) -> [CalendarOccurrence] {
monthOccurrences.filter { calendar.isDate($0.date, inSameDayAs: day) }
}
private func moveMonth(_ offset: Int) {
if let next = calendar.date(byAdding: .month, value: offset, to: displayedMonth) {
displayedMonth = next
selectedDay = next
}
}
}
private struct CalendarOccurrence: Identifiable {
let job: SyncJob
let date: Date
var id: String { "\(job.id.uuidString)-\(date.timeIntervalSince1970)" }
}