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