commit 3b95f53a25a7ce508c1bcdd40614ed0285b0bc4f Author: Mark Gregory Date: Tue Sep 8 14:12:59 2026 +0100 Initial Commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..21b8104 Binary files /dev/null and b/.DS_Store differ diff --git a/AppStore/README.txt b/AppStore/README.txt new file mode 100644 index 0000000..9eaea6d --- /dev/null +++ b/AppStore/README.txt @@ -0,0 +1,12 @@ +Rsync Manager — App Store release assets + +Included: +- RsyncManager-AppIcon-1024.png — 1024×1024 marketing icon master, opaque PNG. +- Xcode Assets.xcassets/AppIcon.appiconset — all required macOS icon raster sizes. +- AccentColor.colorset — native system accent used throughout the UI. + +Before App Store submission: +- Set your Apple Development/Distribution team in Xcode Signing & Capabilities. +- Review the bundle identifier and version/build number. +- Archive with the Release scheme and validate the archive in Xcode Organizer. +- Capture App Store screenshots from the final signed build on your target macOS version. diff --git a/AppStore/RsyncManager-AppIcon-1024.png b/AppStore/RsyncManager-AppIcon-1024.png new file mode 100644 index 0000000..f11c1f3 Binary files /dev/null and b/AppStore/RsyncManager-AppIcon-1024.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..28735e3 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Rsync Manager for macOS + +Native SwiftUI rsync job manager for macOS 14+. + +## Features + +- Persistent saved sync jobs using SwiftData +- Create, edit, duplicate and delete jobs +- Local/mounted-volume destinations +- SSH remote destinations with custom port and SSH key +- Archive, compression, delete, permissions and extended-attribute options +- One-exclusion-per-line rules +- Additional custom rsync arguments +- Dry-run mode +- Run on demand +- Live rsync output +- Cancel active job +- Permanent run history with full command/stdout/stderr/exit code +- Warning before a live `--delete` run +- Automatically prefers `/opt/homebrew/bin/rsync`, then `/usr/local/bin/rsync`, then `/usr/bin/rsync` + +## Open and run + +Open `RsyncManager.xcodeproj` in Xcode and press Run. + +The app deliberately has App Sandbox disabled because a general rsync utility needs to execute rsync/ssh and access arbitrary source/destination paths. Hardened Runtime remains enabled. + +For a current rsync on Apple Silicon: + + brew install rsync + +SSH authentication works with your normal OpenSSH configuration and keys. If no SSH key is specified on a job, `ssh` uses its normal configuration in `~/.ssh/config`. + +## macOS requirement + +macOS 14 or later (SwiftData). + +## Automation + +Rsync Manager supports per-job scheduling (once, hourly, daily, weekly, monthly and custom minute intervals), a month calendar/agenda, and FSEvents-based file watching. Scheduled and watch-triggered runs are silent and always written to History with their trigger source. + +Background automation remains active while the application is running, including when its window is minimised, hidden or closed. The Automation settings include a native macOS Launch at Login option so the scheduler and watchers are available after sign-in. Quitting the application stops its in-process scheduler/watchers until it is launched again. diff --git a/RsyncManager.xcodeproj/project.pbxproj b/RsyncManager.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1a5e390 --- /dev/null +++ b/RsyncManager.xcodeproj/project.pbxproj @@ -0,0 +1,158 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 3E6D35DAB1BD51D9E21E7B3F /* RsyncManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */; }; + FB73BE2A7887F32D512EC848 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2D059072C1E33E64D7DCDF /* ContentView.swift */; }; + C24299D89D285E985C089C0F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344231C687A1F4BDC84AE838 /* SettingsView.swift */; }; + 205FC2ECD561AE0B6EA8BE73 /* SyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13136DBE7B701EB6D0CE478E /* SyncJob.swift */; }; + 0AB9E3362F1DCE2312221B31 /* SyncRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */; }; + 93BD9B27AA1F883F0A05CE3A /* RsyncCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */; }; + CC02106B9AF77855935487F6 /* RsyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F173223A0A15A648115C2B6 /* RsyncRunner.swift */; }; + 87F3C7BF9787C5EF1DFC58CE /* RsyncLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */; }; + 0BBFC488F7DFF17E315AEEF2 /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8999F614BA12AF14ACCB5E3D /* FilePicker.swift */; }; + D2009B9542E292B66BB8A0D4 /* StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5919F7BA545585E4369602 /* StatusBadge.swift */; }; + 3D5AB53FDA192FBBA66D2CEF /* PathField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B5F691C6DE4B3B7224BE4B /* PathField.swift */; }; + 41CA3E36ADC08AE451912D92 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5790F644CCAAF49094D57D68 /* FlowLayout.swift */; }; + 593DB1791A00690020ADF82D /* JobEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */; }; + 7C32064BC9CE29F5116ADE89 /* JobListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2639D73ED1EC5C164BC46AF /* JobListView.swift */; }; + DF2A7BB149EEC298F6207758 /* JobDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */; }; + 1FB249F959D6825750186C76 /* RunHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */; }; + F58FB754850EEF340B2E65FE /* RunLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */; }; + AA1000000000000000000001 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000001 /* KeychainStore.swift */; }; + AA1000000000000000000002 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000002 /* SplashView.swift */; }; + AA1000000000000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000003 /* Assets.xcassets */; }; + AB1000000000000000000001 /* SyncSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000001 /* SyncSchedule.swift */; }; + AB1000000000000000000002 /* FileWatchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000002 /* FileWatchService.swift */; }; + AB1000000000000000000003 /* BackgroundSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000003 /* BackgroundSyncManager.swift */; }; + AB1000000000000000000004 /* ScheduleCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000004 /* ScheduleCalendarView.swift */; }; + AC1000000000000000000001 /* HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000001 /* HelpView.swift */; }; + AC1000000000000000000002 /* RsyncManagerHelp.md in Resources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000002 /* RsyncManagerHelp.md */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RsyncManagerApp.swift"; sourceTree = ""; }; + 4D2D059072C1E33E64D7DCDF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView.swift"; sourceTree = ""; }; + 344231C687A1F4BDC84AE838 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView.swift"; sourceTree = ""; }; + 13136DBE7B701EB6D0CE478E /* SyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncJob.swift"; sourceTree = ""; }; + C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncRun.swift"; sourceTree = ""; }; + F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncCommandBuilder.swift"; sourceTree = ""; }; + 1F173223A0A15A648115C2B6 /* RsyncRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncRunner.swift"; sourceTree = ""; }; + BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncLocator.swift"; sourceTree = ""; }; + 8999F614BA12AF14ACCB5E3D /* FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utilities/FilePicker.swift"; sourceTree = ""; }; + CA5919F7BA545585E4369602 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/StatusBadge.swift"; sourceTree = ""; }; + 58B5F691C6DE4B3B7224BE4B /* PathField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/PathField.swift"; sourceTree = ""; }; + 5790F644CCAAF49094D57D68 /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/FlowLayout.swift"; sourceTree = ""; }; + BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobEditorView.swift"; sourceTree = ""; }; + E2639D73ED1EC5C164BC46AF /* JobListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobListView.swift"; sourceTree = ""; }; + 002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobDetailView.swift"; sourceTree = ""; }; + CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Runs/RunHistoryView.swift"; sourceTree = ""; }; + F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Runs/RunLogView.swift"; sourceTree = ""; }; + D91517BFBDECE389497A425B /* RsyncManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RsyncManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA2000000000000000000001 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utilities/KeychainStore.swift"; sourceTree = ""; }; + AA2000000000000000000002 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/SplashView.swift"; sourceTree = ""; }; + AA2000000000000000000003 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AB2000000000000000000001 /* SyncSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncSchedule.swift"; sourceTree = ""; }; + AB2000000000000000000002 /* FileWatchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/FileWatchService.swift"; sourceTree = ""; }; + AB2000000000000000000003 /* BackgroundSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/BackgroundSyncManager.swift"; sourceTree = ""; }; + AB2000000000000000000004 /* ScheduleCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/ScheduleCalendarView.swift"; sourceTree = ""; }; + AC2000000000000000000001 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/HelpView.swift"; sourceTree = ""; }; + AC2000000000000000000002 /* RsyncManagerHelp.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Resources/RsyncManagerHelp.md"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DAF6AA633395B1B76148E35B /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + FAD58DE7366495DB4650CFEF = {isa = PBXGroup; children = (B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */, 86024CAD1E83101D97359D73 /* Products */); sourceTree = ""; }; + B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */ = {isa = PBXGroup; children = ( + 2C1944E5F7109545301358AD /* RsyncManagerApp.swift */, + 4D2D059072C1E33E64D7DCDF /* ContentView.swift */, + 344231C687A1F4BDC84AE838 /* SettingsView.swift */, + 13136DBE7B701EB6D0CE478E /* SyncJob.swift */, + C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */, + AB2000000000000000000001 /* SyncSchedule.swift */, + F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */, + 1F173223A0A15A648115C2B6 /* RsyncRunner.swift */, + BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */, + AB2000000000000000000002 /* FileWatchService.swift */, + AB2000000000000000000003 /* BackgroundSyncManager.swift */, + 8999F614BA12AF14ACCB5E3D /* FilePicker.swift */, + CA5919F7BA545585E4369602 /* StatusBadge.swift */, + 58B5F691C6DE4B3B7224BE4B /* PathField.swift */, + 5790F644CCAAF49094D57D68 /* FlowLayout.swift */, + BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */, + E2639D73ED1EC5C164BC46AF /* JobListView.swift */, + 002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */, + CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */, + F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */, + AB2000000000000000000004 /* ScheduleCalendarView.swift */, + AC2000000000000000000001 /* HelpView.swift */, + AC2000000000000000000002 /* RsyncManagerHelp.md */, + AA2000000000000000000001 /* KeychainStore.swift */, + AA2000000000000000000002 /* SplashView.swift */, + AA2000000000000000000003 /* Assets.xcassets */, + ); path = RsyncManager; sourceTree = ""; }; + 86024CAD1E83101D97359D73 /* Products */ = {isa = PBXGroup; children = (D91517BFBDECE389497A425B /* RsyncManager.app */); name = Products; sourceTree = ""; }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 42AEFBAE01D2DFD981F7DA7D /* RsyncManager */ = {isa = PBXNativeTarget; buildConfigurationList = 1EE5970F9824F2EE5F7679D7; buildPhases = (FCA229C23F166EBB0BA5029E /* Sources */, DAF6AA633395B1B76148E35B /* Frameworks */, B9C6EB6F79FC54535ACB3053 /* Resources */); buildRules = (); dependencies = (); name = RsyncManager; productName = RsyncManager; productReference = D91517BFBDECE389497A425B /* RsyncManager.app */; productType = "com.apple.product-type.application"; }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 46F86FAA6BBF9AC94A7E4595 /* Project object */ = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; TargetAttributes = {42AEFBAE01D2DFD981F7DA7D = {CreatedOnToolsVersion = 16.0;};};}; buildConfigurationList = F0E5A3A488914D1AA34BF23A; compatibilityVersion = "Xcode 15.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = FAD58DE7366495DB4650CFEF; productRefGroup = 86024CAD1E83101D97359D73; projectDirPath = ""; projectRoot = ""; targets = (42AEFBAE01D2DFD981F7DA7D /* RsyncManager */); }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B9C6EB6F79FC54535ACB3053 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (AA1000000000000000000003 /* Assets.xcassets in Resources */, AC1000000000000000000002 /* RsyncManagerHelp.md in Resources */,); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + FCA229C23F166EBB0BA5029E /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3E6D35DAB1BD51D9E21E7B3F /* RsyncManagerApp.swift in Sources */, + FB73BE2A7887F32D512EC848 /* ContentView.swift in Sources */, + C24299D89D285E985C089C0F /* SettingsView.swift in Sources */, + 205FC2ECD561AE0B6EA8BE73 /* SyncJob.swift in Sources */, + 0AB9E3362F1DCE2312221B31 /* SyncRun.swift in Sources */, + AB1000000000000000000001 /* SyncSchedule.swift in Sources */, + 93BD9B27AA1F883F0A05CE3A /* RsyncCommandBuilder.swift in Sources */, + CC02106B9AF77855935487F6 /* RsyncRunner.swift in Sources */, + 87F3C7BF9787C5EF1DFC58CE /* RsyncLocator.swift in Sources */, + AB1000000000000000000002 /* FileWatchService.swift in Sources */, + AB1000000000000000000003 /* BackgroundSyncManager.swift in Sources */, + 0BBFC488F7DFF17E315AEEF2 /* FilePicker.swift in Sources */, + D2009B9542E292B66BB8A0D4 /* StatusBadge.swift in Sources */, + 3D5AB53FDA192FBBA66D2CEF /* PathField.swift in Sources */, + 41CA3E36ADC08AE451912D92 /* FlowLayout.swift in Sources */, + 593DB1791A00690020ADF82D /* JobEditorView.swift in Sources */, + 7C32064BC9CE29F5116ADE89 /* JobListView.swift in Sources */, + DF2A7BB149EEC298F6207758 /* JobDetailView.swift in Sources */, + 1FB249F959D6825750186C76 /* RunHistoryView.swift in Sources */, + F58FB754850EEF340B2E65FE /* RunLogView.swift in Sources */, + AB1000000000000000000004 /* ScheduleCalendarView.swift in Sources */, + AC1000000000000000000001 /* HelpView.swift in Sources */, + AA1000000000000000000001 /* KeychainStore.swift in Sources */, + AA1000000000000000000002 /* SplashView.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 43E6727D37020658CECE8F0C /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone";}; name = Debug; }; + 643EE4829B317660C2A7A37C /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule;}; name = Release; }; + 0C42D4EB6117C2B9C8008C35 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Rsync Manager"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncManager; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0;}; name = Debug; }; + A969B6690DDF65BACEAA2A89 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Rsync Manager"; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncManager; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0;}; name = Release; }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F0E5A3A488914D1AA34BF23A /* Build configuration list for PBXProject */ = {isa = XCConfigurationList; buildConfigurations = (43E6727D37020658CECE8F0C /* Debug */, 643EE4829B317660C2A7A37C /* Release */); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 1EE5970F9824F2EE5F7679D7 /* Build configuration list for PBXNativeTarget */ = {isa = XCConfigurationList; buildConfigurations = (0C42D4EB6117C2B9C8008C35 /* Debug */, A969B6690DDF65BACEAA2A89 /* Release */); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; +/* End XCConfigurationList section */ + }; + rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */; +} diff --git a/RsyncManager.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/RsyncManager.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/RsyncManager.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/RsyncManager.xcodeproj/project.xcworkspace/xcuserdata/mag.xcuserdatad/UserInterfaceState.xcuserstate b/RsyncManager.xcodeproj/project.xcworkspace/xcuserdata/mag.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..26ac728 Binary files /dev/null and b/RsyncManager.xcodeproj/project.xcworkspace/xcuserdata/mag.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/RsyncManager.xcodeproj/xcuserdata/mag.xcuserdatad/xcschemes/xcschememanagement.plist b/RsyncManager.xcodeproj/xcuserdata/mag.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..960a182 --- /dev/null +++ b/RsyncManager.xcodeproj/xcuserdata/mag.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + RsyncManager.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/RsyncManager/Assets.xcassets/AccentColor.colorset/Contents.json b/RsyncManager/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..740fb4f --- /dev/null +++ b/RsyncManager/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0.078", + "green": "0.455", + "blue": "0.902", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/Contents.json b/RsyncManager/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ccbe64b --- /dev/null +++ b/RsyncManager/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "idiom": "mac", + "size": "16x16", + "scale": "1x", + "filename": "appicon_16x16@1x.png" + }, + { + "idiom": "mac", + "size": "16x16", + "scale": "2x", + "filename": "appicon_16x16@2x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "1x", + "filename": "appicon_32x32@1x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "2x", + "filename": "appicon_32x32@2x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "1x", + "filename": "appicon_128x128@1x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "2x", + "filename": "appicon_128x128@2x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "1x", + "filename": "appicon_256x256@1x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "2x", + "filename": "appicon_256x256@2x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "1x", + "filename": "appicon_512x512@1x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "2x", + "filename": "appicon_512x512@2x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png new file mode 100644 index 0000000..0166945 Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png new file mode 100644 index 0000000..0fce1cf Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png new file mode 100644 index 0000000..de6a01b Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png new file mode 100644 index 0000000..37b29b9 Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png new file mode 100644 index 0000000..0fce1cf Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png new file mode 100644 index 0000000..75e61eb Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png new file mode 100644 index 0000000..37b29b9 Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png new file mode 100644 index 0000000..be6fbfa Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png new file mode 100644 index 0000000..75e61eb Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png differ diff --git a/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png new file mode 100644 index 0000000..f11c1f3 Binary files /dev/null and b/RsyncManager/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png differ diff --git a/RsyncManager/Assets.xcassets/Contents.json b/RsyncManager/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/RsyncManager/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/RsyncManager/ContentView.swift b/RsyncManager/ContentView.swift new file mode 100644 index 0000000..35a7ca4 --- /dev/null +++ b/RsyncManager/ContentView.swift @@ -0,0 +1,214 @@ +import SwiftUI +import SwiftData + +struct ContentView: View { + @EnvironmentObject private var automation: BackgroundSyncManager + @Environment(\.openWindow) private var openWindow + @State private var selectedJob: SyncJob? + @State private var section: AppSection = .jobs + @State private var showingNewJob = false + @State private var showingSplash = true + + var body: some View { + ZStack { + mainContent + .opacity(showingSplash ? 0 : 1) + + if showingSplash { + SplashView() + .transition(.opacity.combined(with: .scale(scale: 1.01))) + .zIndex(10) + } + } + .sheet(isPresented: $showingNewJob) { JobEditorView() } + .task { + guard showingSplash else { return } + try? await Task.sleep(for: .seconds(1.0)) + withAnimation(.easeOut(duration: 0.28)) { showingSplash = false } + } + } + + private var mainContent: some View { + VStack(spacing: 0) { + sectionHeader + Divider() + + switch section { + case .jobs: + HSplitView { + JobListView(selection: $selectedJob, showingNewJob: $showingNewJob) + .frame(minWidth: 320, idealWidth: 360, maxWidth: 390) + + Group { + if let selectedJob { + JobDetailView(job: selectedJob) + } else { + emptyJobsView + } + } + .frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .calendar: + ScheduleCalendarView() + + case .history: + RunHistoryView(onBack: { section = .jobs }) + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var emptyJobsView: some View { + VStack(spacing: 20) { + ZStack { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color.accentColor.opacity(0.10)) + .frame(width: 92, height: 92) + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + + VStack(spacing: 7) { + Text("Select a Sync Job") + .font(.title2.bold()) + Text("Choose a job from the list to review its paths, automation and recent status.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 430) + } + + Button { + showingNewJob = true + } label: { + Label("New Sync Job", systemImage: "plus") + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(40) + } + + private var sectionHeader: some View { + HStack(spacing: 18) { + HStack(spacing: 11) { + Image(systemName: "arrow.triangle.2.circlepath.circle.fill") + .font(.system(size: 27, weight: .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 1) { + Text("Rsync Manager") + .font(.headline) + Text(section.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if automation.activity != .idle { + HStack(spacing: 7) { + AnimatedSyncIndicator(size: 18, lineWidth: 2.6) + Text(automation.activityDisplayTitle) + .font(.caption.weight(.medium)) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.thinMaterial, in: Capsule()) + } + + Picker("Section", selection: $section) { + Label("Jobs", systemImage: "rectangle.stack").tag(AppSection.jobs) + Label("Calendar", systemImage: "calendar").tag(AppSection.calendar) + Label("History", systemImage: "clock.arrow.circlepath").tag(AppSection.history) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 390) + + Button { + openWindow(id: "help") + } label: { + Label("Help", systemImage: "questionmark.circle") + } + .buttonStyle(.bordered) + .help("Open Rsync Manager Help") + } + .padding(.horizontal, 18) + .frame(height: 70) + .background(.bar) + } +} + +enum AppSection: Hashable { + case jobs, calendar, history + + var subtitle: String { + switch self { + case .jobs: return "Jobs & Automation" + case .calendar: return "Schedule Calendar" + case .history: return "Run History" + } + } +} + + +struct AnimatedSyncIndicator: View { + let size: CGFloat + var lineWidth: CGFloat = 7 + + @State private var rotation = 0.0 + @State private var pulse = false + + var body: some View { + ZStack { + Circle() + .stroke(Color.accentColor.opacity(0.10), lineWidth: lineWidth) + + Circle() + .trim(from: 0.05, to: 0.76) + .stroke( + AngularGradient( + colors: [ + Color.accentColor.opacity(0.18), + Color.accentColor, + Color.accentColor.opacity(0.40) + ], + center: .center + ), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(rotation)) + + Circle() + .fill(Color.accentColor) + .frame(width: max(4, lineWidth * 1.05), height: max(4, lineWidth * 1.05)) + .offset(y: -(size / 2)) + .rotationEffect(.degrees(rotation + 18)) + + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: size * 0.31, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .scaleEffect(pulse ? 1.04 : 0.94) + .opacity(pulse ? 1 : 0.72) + } + .frame(width: size, height: size) + .onAppear { + rotation = 0 + pulse = false + withAnimation(.linear(duration: 1.15).repeatForever(autoreverses: false)) { + rotation = 360 + } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} diff --git a/RsyncManager/Models/SyncJob.swift b/RsyncManager/Models/SyncJob.swift new file mode 100644 index 0000000..bf69352 --- /dev/null +++ b/RsyncManager/Models/SyncJob.swift @@ -0,0 +1,90 @@ +import Foundation +import SwiftData + +@Model +final class SyncJob { + var id: UUID + var name: String + var sourcePath: String + var destinationPath: String + var isRemote: Bool + var remoteHost: String + var remoteUser: String + var remotePort: Int + var sshKeyPath: String + var archive: Bool + var compress: Bool + var deleteExtraneous: Bool + var preservePermissions: Bool + var preserveExtendedAttributes: Bool + var showProgress: Bool + var exclusionsText: String + var additionalArguments: String + var isEnabled: Bool + var createdAt: Date + var updatedAt: Date + var lastRunAt: Date? + var lastExitCode: Int? + var scheduleEnabled: Bool = false + var scheduleKindRaw: String = SyncScheduleKind.manual.rawValue + var scheduleStartAt: Date = Date().addingTimeInterval(3600) + var scheduleIntervalMinutes: Int = 30 + var nextRunAt: Date? = nil + var watchEnabled: Bool = false + var watchPath: String = "" + var watchDebounceSeconds: Int = 3 + var lastWatchEventAt: Date? = nil + + init( + name: String = "New Sync Job", + sourcePath: String = "", + destinationPath: String = "" + ) { + self.id = UUID() + self.name = name + self.sourcePath = sourcePath + self.destinationPath = destinationPath + self.isRemote = false + self.remoteHost = "" + self.remoteUser = "" + self.remotePort = 22 + self.sshKeyPath = "" + self.archive = true + self.compress = false + self.deleteExtraneous = false + self.preservePermissions = true + self.preserveExtendedAttributes = true + self.showProgress = true + self.exclusionsText = "" + self.additionalArguments = "" + self.isEnabled = true + self.createdAt = Date() + self.updatedAt = Date() + self.lastRunAt = nil + self.lastExitCode = nil + self.scheduleEnabled = false + self.scheduleKindRaw = SyncScheduleKind.manual.rawValue + self.scheduleStartAt = Date().addingTimeInterval(3600) + self.scheduleIntervalMinutes = 30 + self.nextRunAt = nil + self.watchEnabled = false + self.watchPath = "" + self.watchDebounceSeconds = 3 + self.lastWatchEventAt = nil + } + + var exclusions: [String] { + exclusionsText + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + var destinationDisplay: String { + if isRemote { + let userPrefix = remoteUser.isEmpty ? "" : "\(remoteUser)@" + return "\(userPrefix)\(remoteHost):\(destinationPath)" + } + return destinationPath + } +} diff --git a/RsyncManager/Models/SyncRun.swift b/RsyncManager/Models/SyncRun.swift new file mode 100644 index 0000000..d62a7c5 --- /dev/null +++ b/RsyncManager/Models/SyncRun.swift @@ -0,0 +1,129 @@ +import Foundation +import SwiftData + +@Model +final class SyncRun { + var id: UUID + var jobID: UUID + var jobName: String + var startedAt: Date + var finishedAt: Date? + var command: String + var output: String + var errorOutput: String + var exitCode: Int? + var wasDryRun: Bool + var wasCancelled: Bool + var triggerRaw: String = SyncRunTrigger.manual.rawValue + + init(job: SyncJob, command: String, wasDryRun: Bool, trigger: SyncRunTrigger = .manual) { + self.id = UUID() + self.jobID = job.id + self.jobName = job.name + self.startedAt = Date() + self.command = command + self.output = "" + self.errorOutput = "" + self.exitCode = nil + self.wasDryRun = wasDryRun + self.wasCancelled = false + self.triggerRaw = trigger.rawValue + } + + var trigger: SyncRunTrigger { SyncRunTrigger(rawValue: triggerRaw) ?? .manual } + var succeeded: Bool { exitCode == 0 && !wasCancelled } + var isRunning: Bool { finishedAt == nil } + + var duration: TimeInterval? { + guard let finishedAt else { return nil } + return finishedAt.timeIntervalSince(startedAt) + } + + var summary: RsyncSummary { + RsyncSummary.parse(output + "\n" + errorOutput) + } + + var exportText: String { + var text = "Rsync Manager Run Log\n" + text += "Job: \(jobName)\n" + text += "Started: \(startedAt.formatted(date: .complete, time: .standard))\n" + if let finishedAt { text += "Finished: \(finishedAt.formatted(date: .complete, time: .standard))\n" } + text += "Trigger: \(trigger.title)\n" + text += "Dry run: \(wasDryRun ? "Yes" : "No")\n" + text += "\nCommand\n-------\n$ \(command)\n\nOutput\n------\n" + if !output.isEmpty { text += output } + if !errorOutput.isEmpty { + if !text.hasSuffix("\n") { text += "\n" } + text += "\n--- stderr ---\n\(errorOutput)" + } + if let exitCode { + if !text.hasSuffix("\n") { text += "\n" } + text += "\nExit code: \(exitCode)\n" + } + return text + } +} + +struct RsyncSummary: Equatable { + var numberOfFiles: String? + var numberOfFilesTransferred: String? + var totalFileSize: String? + var totalTransferredFileSize: String? + var unmatchedData: String? + var matchedData: String? + var fileListSize: String? + var fileListGenerationTime: String? + var fileListTransferTime: String? + var totalSent: String? + var totalReceived: String? + var transferRate: String? + var totalSizeAndSpeedup: String? + + var hasStatistics: Bool { + [ + numberOfFiles, + numberOfFilesTransferred, + totalFileSize, + totalTransferredFileSize, + totalSent, + totalReceived + ].contains { $0 != nil } + } + + static func parse(_ text: String) -> RsyncSummary { + var result = RsyncSummary() + + for rawLine in text.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } + + func value(after prefix: String) -> String? { + guard line.hasPrefix(prefix) else { return nil } + return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + + if let value = value(after: "Number of files:") { result.numberOfFiles = value; continue } + if let value = value(after: "Number of files transferred:") { result.numberOfFilesTransferred = value; continue } + if let value = value(after: "Total file size:") { result.totalFileSize = value; continue } + if let value = value(after: "Total transferred file size:") { result.totalTransferredFileSize = value; continue } + if let value = value(after: "Unmatched data:") { result.unmatchedData = value; continue } + if let value = value(after: "Matched data:") { result.matchedData = value; continue } + if let value = value(after: "File list size:") { result.fileListSize = value; continue } + if let value = value(after: "File list generation time:") { result.fileListGenerationTime = value; continue } + if let value = value(after: "File list transfer time:") { result.fileListTransferTime = value; continue } + if let value = value(after: "Total sent:") { result.totalSent = value; continue } + if let value = value(after: "Total received:") { result.totalReceived = value; continue } + + if line.hasPrefix("sent "), line.contains(" bytes/sec") { + result.transferRate = line + continue + } + + if line.hasPrefix("total size is "), line.contains("speedup is") { + result.totalSizeAndSpeedup = line + } + } + + return result + } +} diff --git a/RsyncManager/Models/SyncSchedule.swift b/RsyncManager/Models/SyncSchedule.swift new file mode 100644 index 0000000..0a13809 --- /dev/null +++ b/RsyncManager/Models/SyncSchedule.swift @@ -0,0 +1,142 @@ +import Foundation + +enum SyncScheduleKind: String, CaseIterable, Identifiable, Codable { + case manual + case once + case hourly + case daily + case weekly + case monthly + case custom + + var id: String { rawValue } + + var title: String { + switch self { + case .manual: return "Manual only" + case .once: return "Once" + case .hourly: return "Hourly" + case .daily: return "Daily" + case .weekly: return "Weekly" + case .monthly: return "Monthly" + case .custom: return "Custom interval" + } + } + + var systemImage: String { + switch self { + case .manual: return "hand.tap" + case .once: return "calendar.badge.clock" + case .hourly: return "clock.arrow.circlepath" + case .daily: return "sun.max" + case .weekly: return "calendar" + case .monthly: return "calendar.circle" + case .custom: return "timer" + } + } +} + +enum SyncRunTrigger: String, Codable { + case manual + case scheduled + case watched + case syncAll + + var title: String { + switch self { + case .manual: return "Manual" + case .scheduled: return "Scheduled" + case .watched: return "File change" + case .syncAll: return "Sync All" + } + } + + var systemImage: String { + switch self { + case .manual: return "play.fill" + case .scheduled: return "calendar.badge.clock" + case .watched: return "eye.fill" + case .syncAll: return "arrow.triangle.2.circlepath" + } + } +} + +extension SyncJob { + var scheduleKind: SyncScheduleKind { + get { SyncScheduleKind(rawValue: scheduleKindRaw) ?? .manual } + set { scheduleKindRaw = newValue.rawValue } + } + + var effectiveWatchPath: String { + let trimmed = watchPath.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? sourcePath : trimmed + } + + var scheduleDescription: String { + guard scheduleEnabled, scheduleKind != .manual else { return "Manual" } + let time = scheduleStartAt.formatted(date: .omitted, time: .shortened) + switch scheduleKind { + case .manual: return "Manual" + case .once: return "Once · \(scheduleStartAt.formatted(date: .abbreviated, time: .shortened))" + case .hourly: return "Every hour" + case .daily: return "Daily · \(time)" + case .weekly: + return "Weekly · \(scheduleStartAt.formatted(.dateTime.weekday(.abbreviated))) · \(time)" + case .monthly: + return "Monthly · day \(Calendar.current.component(.day, from: scheduleStartAt)) · \(time)" + case .custom: + return "Every \(max(1, scheduleIntervalMinutes)) min" + } + } + + func calculateNextRun(after date: Date = Date()) -> Date? { + guard scheduleEnabled, scheduleKind != .manual else { return nil } + let calendar = Calendar.current + + switch scheduleKind { + case .manual: + return nil + case .once: + return scheduleStartAt > date ? scheduleStartAt : nil + case .hourly: + return nextInterval(after: date, seconds: 3600) + case .custom: + return nextInterval(after: date, seconds: TimeInterval(max(1, scheduleIntervalMinutes) * 60)) + case .daily: + let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) + return calendar.nextDate(after: date, matching: time, matchingPolicy: .nextTime) + case .weekly: + var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) + components.weekday = calendar.component(.weekday, from: scheduleStartAt) + return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime) + case .monthly: + var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) + components.day = calendar.component(.day, from: scheduleStartAt) + return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime) + } + } + + func occurrences(from start: Date, through end: Date, limit: Int = 400) -> [Date] { + guard scheduleEnabled, scheduleKind != .manual, start < end else { return [] } + var output: [Date] = [] + var cursor = start.addingTimeInterval(-1) + + if scheduleKind == .once { + return (scheduleStartAt >= start && scheduleStartAt < end) ? [scheduleStartAt] : [] + } + + while output.count < limit, let next = calculateNextRun(after: cursor), next < end { + if next >= start { output.append(next) } + cursor = next.addingTimeInterval(0.5) + } + return output + } + + private func nextInterval(after date: Date, seconds: TimeInterval) -> Date? { + guard seconds > 0 else { return nil } + if scheduleStartAt > date { return scheduleStartAt } + let elapsed = date.timeIntervalSince(scheduleStartAt) + let steps = floor(elapsed / seconds) + 1 + return scheduleStartAt.addingTimeInterval(steps * seconds) + } +} diff --git a/RsyncManager/Resources/RsyncManagerHelp.md b/RsyncManager/Resources/RsyncManagerHelp.md new file mode 100644 index 0000000..cc659fe --- /dev/null +++ b/RsyncManager/Resources/RsyncManagerHelp.md @@ -0,0 +1,513 @@ +# Rsync Manager Help + +Rsync Manager is a macOS front end for `rsync`. It can run local copies, SSH-based remote synchronisation, dry runs, scheduled jobs, and file/folder watch jobs. Every run is recorded in History with the command, result and transfer statistics. + +## 1. Before You Start + +Rsync Manager looks for rsync in this order: + +1. `/opt/homebrew/bin/rsync` +2. `/usr/local/bin/rsync` +3. `/usr/bin/rsync` + +For the best compatibility with modern Linux servers, install a current rsync using Homebrew. The macOS supplied `/usr/bin/rsync` is older and behaves differently for some extended-attribute options. + +SSH jobs also require the normal macOS `ssh` client and network access to the remote computer. + +## 2. Create a Local Sync Job + +1. Select **New Sync Job**. +2. Give the job a descriptive name, for example `Documents Backup`. +3. Choose **Local** as the destination type. +4. Select the source folder. +5. Select the destination folder. +6. Choose the required rsync options. +7. Save the job. +8. Run **Dry Run** first, then use **Run Now** when the result looks correct. + +### Example + +Source: + +`/Users/mag/Documents/` + +Destination: + +`/Volumes/Backup/Documents/` + +A typical job using Archive mode is equivalent to: + +`rsync -a --human-readable --stats /Users/mag/Documents/ /Volumes/Backup/Documents/` + +### Trailing slash behaviour + +Rsync treats these differently: + +`/Users/mag/Documents/` + +copies the **contents** of Documents into the destination. + +`/Users/mag/Documents` + +copies the **Documents directory itself** into the destination. + +Check the source carefully before enabling Delete mode. + +## 3. Create an SSH Remote Job + +1. Create or edit a job. +2. Select **SSH Remote**. +3. Enter the remote host, for example `192.168.1.2` or `pegasus.local`. +4. Enter the SSH username, for example `mag`. +5. Set the SSH port, normally `22`. +6. Select a **private** SSH key if required. +7. Enter a remote destination path such as `/home/mag/backups/Documents`. +8. Save and run a Dry Run. + +### Correct remote example + +Host: `pegasus.local` + +Username: `mag` + +Destination: `/home/mag/backups/photos` + +The resulting destination is similar to: + +`mag@pegasus.local:/home/mag/backups/photos` + +### Incorrect remote destination + +Do not put the host address in the Destination field. These are wrong: + +`192.168.1.2` + +`mag@192.168.1.2` + +The Destination field must contain a path on the remote machine. + +## 4. SSH Keys + +Use the private key, not the `.pub` file. + +Correct examples: + +`~/.ssh/id_ed25519` + +`~/.ssh/id_rsa` + +Incorrect: + +`~/.ssh/id_ed25519.pub` + +If you accidentally select a `.pub` key and the matching private key exists beside it, Rsync Manager attempts to use the private key. If only the public key exists, the run is rejected. + +### Test SSH outside the app + +Before troubleshooting rsync, verify SSH itself works: + +`ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local` + +If that command cannot connect, fix SSH/network/authentication first. + +## 5. Default Connection Settings + +Open **Rsync Manager > Settings > Connections** to configure defaults for new SSH jobs: + +- Default username +- Default host +- Default SSH port +- Default private key +- Default password storage + +Passwords are stored in macOS Keychain, not SwiftData or UserDefaults. Current rsync execution is designed primarily around SSH keys or an SSH agent; a stored password is not placed on the command line. + +## 6. Rsync Options + +### Archive + +Archive mode (`-a`) is the normal choice for directory backup/synchronisation. It recursively copies files and preserves common metadata. + +### Compress + +Compress (`-z`) can help on slower network links. It is often unnecessary for local copies or already-compressed files such as JPEG, ZIP and video. + +### Delete Extraneous Files + +Delete mode (`--delete`) removes destination files that no longer exist at the source. + +**Use Dry Run before enabling a destructive sync.** A mistaken source or destination combined with Delete can remove destination data. + +### Preserve Permissions + +Preserves permissions when Archive mode is not being used. + +### Extended Attributes + +On modern rsync, Rsync Manager uses `-X`. With Apple `/usr/bin/rsync`, local jobs use the Apple-compatible `-E` form. For SSH to modern Linux rsync, the Apple-only extended attribute flag is omitted to avoid an incompatible remote option. + +### Exclusions + +Add patterns for files or directories you do not want transferred. + +Examples: + +`.DS_Store` + +`*.tmp` + +`node_modules/` + +`.git/` + +### Additional arguments + +Advanced rsync options can be entered as additional arguments. Use this only when you understand the rsync option and its effect. + +Example: + +`--max-size=2G` + +## 7. Dry Run + +Dry Run adds `--dry-run`. Rsync reports what it **would** change without transferring or deleting files. + +Use Dry Run when: + +- Creating a new job +- Changing source or destination paths +- Enabling Delete +- Changing exclusions +- Changing SSH destinations +- Testing a large sync + +The summary window shows files checked, files that would transfer, data size and transfer statistics. + +## 8. Run Now + +Select a job and choose **Run Now**. Manual runs display a progress/result sheet. The complete stdout/stderr output is retained in History. + +Only one rsync process is run by the current runner at a time. If another run is already active, a second job waits for the automation manager or is rejected for a direct manual launch depending on how it was triggered. + +## 9. Scheduling Jobs + +Edit a job and enable **Schedule**. + +Available schedule types include: + +- Once +- Hourly +- Daily +- Weekly +- Monthly +- Custom interval + +Scheduled runs execute without displaying the manual progress sheet. Results still appear in History. + +### Daily example + +Job: `Nightly Documents` + +Schedule: Daily + +Start time: `23:30` + +Result: the job runs each day at approximately the configured application schedule time while Rsync Manager is running. + +### Weekly example + +Job: `Weekly Photos` + +Schedule: Weekly + +Choose the desired start date/time. The application calculates and displays the next run. + +### Custom interval example + +Set a custom interval of `30` minutes to request a run every 30 minutes. + +Avoid extremely short intervals for large jobs. A new run should not repeatedly start before the previous transfer has finished. + +## 10. Calendar + +Select **Calendar** at the top of the main window. + +The calendar shows scheduled jobs by date and allows you to review upcoming automated work. Use it to confirm that a newly edited schedule produces the next run you expected. + +If a job does not appear: + +1. Confirm the job is enabled. +2. Confirm Schedule is enabled for that job. +3. Confirm the start date is valid. +4. Return to the job and check the displayed **Next scheduled run** value. + +## 11. Watch for File or Folder Changes + +Edit a job and enable **File Watching**. + +Rsync Manager uses macOS FSEvents rather than repeatedly scanning the whole directory tree. + +### Watch the job source directory + +Leave the custom watch path empty. The job source is watched automatically. + +Example source: + +`/Users/mag/Developer/MyProject/` + +When changes occur beneath that directory, the watch service debounces the events and triggers the configured sync. + +### Watch one specific file + +Set the watch path to a specific file, for example: + +`/Users/mag/Documents/config.json` + +The service watches the containing directory and filters events so only changes affecting that file trigger the job. + +### Debounce + +Applications often save a file several times in quick succession. The debounce period groups those events into one rsync run. + +Example: + +Debounce: `5 seconds` + +Saving 10 files over a three-second period results in a single sync after activity settles, rather than 10 separate rsync processes. + +### Good watch-job examples + +- Sync a development project to a test server after edits. +- Mirror a document directory to a local backup disk. +- Upload generated reports when files change. +- Synchronise a configuration directory to another Mac or Linux machine. + +### Avoid watch loops + +Do not configure the destination inside the watched source tree. Otherwise rsync's own writes can generate more filesystem events and repeatedly trigger the job. + +Bad example: + +Source: `/Users/mag/Data/` + +Destination: `/Users/mag/Data/Backup/` + +Prefer a destination outside the watched source hierarchy. + +## 12. Background Operation + +Scheduled and file-watch jobs run without opening the manual progress sheet. The application must still be running for its scheduler and FSEvents watchers to operate. + +Enable **Launch at Login** in Settings if you want Rsync Manager to start automatically after you sign in to macOS. You can then close or minimise the main window while the app continues running. + +Quitting Rsync Manager stops in-process schedules and file watching until the application starts again. + +## 13. Jobs Screen + +The Jobs screen is the main status dashboard. Each job shows its key automation and run state. + +Use the filters to review: + +- All jobs +- Scheduled jobs +- Watching jobs +- Jobs with issues + +Select a job to open its full detail view. The **Edit Job** button remains fixed at the top of the detail pane so it is available even after scrolling or on narrower windows. + +## 14. History + +Select **History** to review completed runs. + +History records whether the run was started by: + +- Manual action +- Schedule +- File change + +Select a run to inspect: + +- Start/end time +- Exit status +- rsync command +- Transfer summary +- stdout +- stderr +- Dry-run state + +Use **Export Log** from the run result/detail view when you need a text copy for troubleshooting or auditing. + +## 15. Common Rsync Exit Codes + +`0` — Success. + +`1` — Syntax or usage error. + +`2` — Protocol incompatibility. + +`3` — File selection/input-output errors. + +`10` — Socket I/O error. + +`11` — File I/O error. + +`12` — Error in rsync protocol data stream. + +`23` — Partial transfer due to error. + +`24` — Partial transfer because source files vanished during the run. + +`30` — Timeout in data send/receive. + +Always inspect stderr in History for the actual diagnostic message. + +## 16. Troubleshooting + +### Permission denied + +Check that the macOS user running Rsync Manager can read the source and write to the destination. For remote jobs, check permissions for the remote SSH user. + +### Host key verification failed + +Connect once using Terminal: + +`ssh mag@pegasus.local` + +Verify the host fingerprint and allow SSH to update `~/.ssh/known_hosts`. + +Do not disable host-key verification simply to hide this warning. + +### Public key authentication failed + +Check: + +- The private key path is correct. +- The remote user's `~/.ssh/authorized_keys` contains the matching public key. +- File permissions on the remote `.ssh` directory/key files are valid. +- The SSH username is correct. + +### Remote rsync option error + +If you are using `/usr/bin/rsync` on macOS against modern Linux, avoid manually adding Apple-specific rsync options. Installing Homebrew rsync provides better modern compatibility. + +### Nothing transfers + +Run a Dry Run and inspect the command and output. Check exclusions, trailing slash behaviour and whether source/destination already contain identical data. + +### Watch job does not trigger + +Check that: + +- The job is enabled. +- Global file watching is enabled in Settings. +- File Watching is enabled on the job. +- The watch path exists. +- Rsync Manager is still running. +- The change occurred beneath the watched path, or matches the selected single file. + +### Schedule does not run + +Check that: + +- Global scheduling is enabled in Settings. +- The job itself is enabled. +- Schedule is enabled for the job. +- The displayed next-run time is correct. +- Rsync Manager is running at the scheduled time. + +## 17. Recommended Backup Workflow + +For an important backup job: + +1. Create the job without Delete enabled. +2. Run a Dry Run. +3. Review the source and destination in the generated command. +4. Run the job manually. +5. Check History for exit code 0. +6. Verify several files at the destination. +7. Only then enable a schedule. +8. Enable Delete only when you specifically require a mirror and understand the deletion behaviour. + +Rsync synchronisation is not automatically a versioned backup. If the destination mirrors source deletions, it does not replace a separate versioned/offline backup strategy. + +## 18. Example Configurations + +### Local external-drive backup + +Name: `Documents to USB` + +Source: `/Users/mag/Documents/` + +Destination: `/Volumes/Backup/Documents/` + +Archive: On + +Delete: Off initially + +Schedule: Daily at 22:00 + +### Mac to Linux server + +Name: `Projects to Pegasus` + +Source: `/Users/mag/Developer/` + +Host: `pegasus.local` + +User: `mag` + +Port: `22` + +Private key: `/Users/mag/.ssh/id_ed25519` + +Remote destination: `/home/mag/backups/Developer/` + +Archive: On + +Compress: On + +Schedule: Daily + +### Live project watcher + +Name: `Website Live Sync` + +Source: `/Users/mag/Sites/MySite/` + +Remote destination: `/var/www/mysite/` + +Watch: On + +Watch path: leave empty to watch the source + +Debounce: 5 seconds + +Exclusions: `.git/`, `node_modules/`, `.DS_Store` + +Use this style only when automatic deployment to that destination is intentional. + +## 19. Safety Checklist + +Before running a new job, confirm: + +- Source is correct. +- Destination is correct. +- Remote host/user are correct. +- The selected SSH key is private, not `.pub`. +- Delete mode is intentional. +- Exclusions are correct. +- Dry Run output looks correct. +- The destination has enough free space. +- A watch destination is not inside its watched source. + +## 20. Getting Help Information from the App + +Select the **Help** button in the main Rsync Manager header. The bundled guide you are reading opens inside the application and is available without an Internet connection. + +For an rsync-specific option not documented here, run `man rsync` in Terminal for the version of rsync installed on your Mac. Behaviour can differ between the Apple-supplied rsync and current Homebrew rsync. + + +## Sync All + +Use **Sync All** in the Jobs sidebar to run every enabled job in sequence. Rsync Manager queues the jobs rather than starting several rsync processes at once. Each job creates its own History entry, and the header shows the current batch progress. Disabled jobs are skipped. + diff --git a/RsyncManager/RsyncManagerApp.swift b/RsyncManager/RsyncManagerApp.swift new file mode 100644 index 0000000..09b8826 --- /dev/null +++ b/RsyncManager/RsyncManagerApp.swift @@ -0,0 +1,64 @@ +import SwiftUI +import SwiftData + +@main +@MainActor +struct RsyncManagerApp: App { + let sharedModelContainer: ModelContainer + @StateObject private var runner: RsyncRunner + @StateObject private var automation: BackgroundSyncManager + + init() { + let schema = Schema([ + SyncJob.self, + SyncRun.self, + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + do { + let container = try ModelContainer(for: schema, configurations: [configuration]) + sharedModelContainer = container + let rsyncRunner = RsyncRunner() + _runner = StateObject(wrappedValue: rsyncRunner) + _automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: container.mainContext, runner: rsyncRunner)) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + } + + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 1040, minHeight: 680) + .environmentObject(runner) + .environmentObject(automation) + .onAppear { automation.start() } + } + .modelContainer(sharedModelContainer) + .commands { + RsyncManagerCommands() + } + + Window("Rsync Manager Help", id: "help") { + HelpView() + } + .defaultSize(width: 940, height: 780) + + Settings { + SettingsView() + .environmentObject(automation) + } + } +} + +private struct RsyncManagerCommands: Commands { + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(replacing: .help) { + Button("Rsync Manager Help") { + openWindow(id: "help") + } + .keyboardShortcut("?", modifiers: [.command]) + } + } +} diff --git a/RsyncManager/Services/BackgroundSyncManager.swift b/RsyncManager/Services/BackgroundSyncManager.swift new file mode 100644 index 0000000..33229ce --- /dev/null +++ b/RsyncManager/Services/BackgroundSyncManager.swift @@ -0,0 +1,245 @@ +import Foundation +import SwiftData + +@MainActor +final class BackgroundSyncManager: ObservableObject { + enum Activity: Equatable { + case idle + case waiting(String) + case running(String) + + var title: String { + switch self { + case .idle: return "Idle" + case .waiting(let name): return "Waiting · \(name)" + case .running(let name): return "Running · \(name)" + } + } + } + + @Published private(set) var activity: Activity = .idle + @Published private(set) var watchedJobCount = 0 + @Published private(set) var scheduledJobCount = 0 + @Published private(set) var isSyncAllRunning = false + @Published private(set) var syncAllCompleted = 0 + @Published private(set) var syncAllTotal = 0 + + var activityDisplayTitle: String { + if isSyncAllRunning { + return "Sync All · \(min(syncAllCompleted + 1, syncAllTotal)) of \(syncAllTotal)" + } + return activity.title + } + + private let modelContext: ModelContext + private let runner: RsyncRunner + private let watcher = FileWatchService() + private var timer: Timer? + private var pending: [(UUID, SyncRunTrigger)] = [] + private var pendingIDs = Set() + private var isProcessingQueue = false + private var watchDebounceTasks: [UUID: Task] = [:] + private var started = false + private var notificationToken: NSObjectProtocol? + + init(modelContext: ModelContext, runner: RsyncRunner) { + self.modelContext = modelContext + self.runner = runner + watcher.onChange = { [weak self] jobID, path in + Task { @MainActor [weak self] in + self?.fileChanged(jobID: jobID, path: path) + } + } + notificationToken = NotificationCenter.default.addObserver( + forName: .syncJobsChanged, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.refreshConfiguration() } + } + } + + deinit { + if let notificationToken { NotificationCenter.default.removeObserver(notificationToken) } + } + + func start() { + guard !started else { return } + started = true + refreshConfiguration() + checkSchedules() + timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshConfiguration() + self?.checkSchedules() + self?.processQueue() + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + watcher.stop() + watchDebounceTasks.values.forEach { $0.cancel() } + watchDebounceTasks.removeAll() + started = false + } + + func syncAllEnabledJobs() { + guard !isSyncAllRunning else { return } + + let enabledJobs = fetchJobs().filter(\.isEnabled) + syncAllCompleted = 0 + syncAllTotal = 0 + + for job in enabledJobs { + if enqueue(jobID: job.id, trigger: .syncAll) { + syncAllTotal += 1 + } + } + + isSyncAllRunning = syncAllTotal > 0 + if !isSyncAllRunning { + syncAllCompleted = 0 + } + } + + func refreshConfiguration() { + let jobs = fetchJobs() + scheduledJobCount = jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }.count + watchedJobCount = jobs.filter { $0.isEnabled && $0.watchEnabled }.count + + if UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true { + watcher.update(jobs: jobs) + } else { + watcher.update(jobs: []) + } + + for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil { + job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) + } + try? modelContext.save() + } + + private func checkSchedules() { + guard UserDefaults.standard.object(forKey: "backgroundSchedulesEnabled") as? Bool ?? true else { return } + let now = Date() + for job in fetchJobs() where job.isEnabled && job.scheduleEnabled && job.scheduleKind != .manual { + guard let next = job.nextRunAt, next <= now else { continue } + enqueue(jobID: job.id, trigger: .scheduled) + + if job.scheduleKind == .once { + job.scheduleEnabled = false + job.nextRunAt = nil + } else { + job.nextRunAt = job.calculateNextRun(after: now) + } + } + try? modelContext.save() + } + + private func fileChanged(jobID: UUID, path: String) { + guard UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true else { return } + guard let job = fetchJob(id: jobID), job.isEnabled, job.watchEnabled else { return } + job.lastWatchEventAt = Date() + watchDebounceTasks[jobID]?.cancel() + let delay = max(1, job.watchDebounceSeconds) + watchDebounceTasks[jobID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + _ = self.enqueue(jobID: jobID, trigger: .watched) + } + try? modelContext.save() + } + + @discardableResult + private func enqueue(jobID: UUID, trigger: SyncRunTrigger) -> Bool { + guard !pendingIDs.contains(jobID), runner.currentJobID != jobID else { return false } + pending.append((jobID, trigger)) + pendingIDs.insert(jobID) + processQueue() + return true + } + + private func processQueue() { + guard !isProcessingQueue, !runner.isRunning, !pending.isEmpty else { + if (isProcessingQueue || runner.isRunning), let first = pending.first, let job = fetchJob(id: first.0) { + activity = .waiting(job.name) + } + return + } + + let item = pending.removeFirst() + pendingIDs.remove(item.0) + guard let job = fetchJob(id: item.0), job.isEnabled else { + finishSyncAllItemIfNeeded(item.1) + processQueue() + return + } + + isProcessingQueue = true + activity = .running(job.name) + Task { [weak self] in + await self?.execute(job: job, trigger: item.1) + await MainActor.run { + guard let self else { return } + self.finishSyncAllItemIfNeeded(item.1) + self.isProcessingQueue = false + self.activity = .idle + self.processQueue() + } + } + } + + private func finishSyncAllItemIfNeeded(_ trigger: SyncRunTrigger) { + switch trigger { + case .syncAll: + syncAllCompleted += 1 + if syncAllCompleted >= syncAllTotal { + isSyncAllRunning = false + } + case .manual, .scheduled, .watched: + break + } + } + + private func execute(job: SyncJob, trigger: SyncRunTrigger) async { + let executable = RsyncLocator.locate() + let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable) + let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger) + modelContext.insert(run) + try? modelContext.save() + + do { + let result = try await runner.run(job: job, dryRun: false) + run.output = result.stdout + run.errorOutput = 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() + } catch { + run.errorOutput = error.localizedDescription + run.exitCode = -1 + run.finishedAt = Date() + job.lastRunAt = run.finishedAt + job.lastExitCode = -1 + } + try? modelContext.save() + } + + private func fetchJobs() -> [SyncJob] { + (try? modelContext.fetch(FetchDescriptor(sortBy: [SortDescriptor(\SyncJob.name)]))) ?? [] + } + + private func fetchJob(id: UUID) -> SyncJob? { + fetchJobs().first { $0.id == id } + } +} + +extension Notification.Name { + static let syncJobsChanged = Notification.Name("RsyncManager.syncJobsChanged") +} diff --git a/RsyncManager/Services/FileWatchService.swift b/RsyncManager/Services/FileWatchService.swift new file mode 100644 index 0000000..a4e66ac --- /dev/null +++ b/RsyncManager/Services/FileWatchService.swift @@ -0,0 +1,94 @@ +import Foundation +import CoreServices + +final class FileWatchService { + typealias ChangeHandler = @Sendable (UUID, String) -> Void + + private struct WatchTarget: Equatable { + let targetPath: String + let streamRoot: String + let isDirectory: Bool + } + + private var stream: FSEventStreamRef? + private var watchedTargets: [UUID: WatchTarget] = [:] + private let queue = DispatchQueue(label: "net.absdev.RsyncManager.fsevents", qos: .utility) + var onChange: ChangeHandler? + + deinit { stop() } + + func update(jobs: [SyncJob]) { + var targets: [UUID: WatchTarget] = [:] + for job in jobs where job.isEnabled && job.watchEnabled { + let path = (job.effectiveWatchPath as NSString).expandingTildeInPath + guard !path.isEmpty else { continue } + + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + let directory = exists ? isDirectory.boolValue : true + let streamRoot = directory ? path : (path as NSString).deletingLastPathComponent + targets[job.id] = WatchTarget(targetPath: path, streamRoot: streamRoot, isDirectory: directory) + } + guard targets != watchedTargets else { return } + watchedTargets = targets + rebuild() + } + + func stop() { + if let stream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } + } + + private func rebuild() { + stop() + let paths = Array(Set(watchedTargets.values.map(\.streamRoot))) + guard !paths.isEmpty else { return } + + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + let callback: FSEventStreamCallback = { _, info, count, eventPaths, _, _ in + guard let info else { return } + let service = Unmanaged.fromOpaque(info).takeUnretainedValue() + let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? [] + for index in 0.. [String] { + var args: [String] = [] + + if job.archive { + args.append("-a") + } else if job.preservePermissions { + args.append("-p") + } + + if job.compress { args.append("-z") } + + // Apple's bundled /usr/bin/rsync uses the Apple-specific -E flag for + // extended attributes. That flag is not compatible with a standard + // rsync server over SSH (for example Linux rsync 3.x), because Apple + // rsync expands it to --extended-attributes on the remote side. + // + // Therefore: + // • Apple rsync + local job -> -E + // • Apple rsync + SSH job -> omit xattr flag + // • Modern/Homebrew rsync -> -X (standard rsync xattrs) + if job.preserveExtendedAttributes { + if isAppleRsync(executable) { + if !job.isRemote { + args.append("-E") + } + } else { + args.append("-X") + } + } + + if job.deleteExtraneous { args.append("--delete") } + + // --info=progress2 is not supported by Apple's bundled rsync 2.6.9. + // --progress works on both Apple's rsync and modern Homebrew rsync. + if job.showProgress { args.append("--progress") } + + if dryRun { args.append("--dry-run") } + + args += ["--human-readable", "--stats"] + + for exclusion in job.exclusions { + args.append("--exclude=\(exclusion)") + } + + if !job.additionalArguments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += shellSplit(job.additionalArguments) + } + + if job.isRemote { + var sshParts = ["ssh", "-p", String(job.remotePort)] + let keyPath = normalizedPrivateKeyPath(job.sshKeyPath) + if !keyPath.isEmpty { + sshParts += ["-i", keyPath] + } + args += ["-e", sshParts.map(shellEscape).joined(separator: " ")] + } + + args.append(normalizeSource(job.sourcePath)) + args.append(job.destinationDisplay) + return args + } + + static func displayCommand(job: SyncJob, dryRun: Bool, executable: String) -> String { + ([executable] + build(job: job, dryRun: dryRun, executable: executable)) + .map(shellEscape) + .joined(separator: " ") + } + + static func normalizedPrivateKeyPath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasSuffix(".pub") else { return trimmed } + + let privatePath = String(trimmed.dropLast(4)) + if FileManager.default.fileExists(atPath: privatePath) { + return privatePath + } + return trimmed + } + + private static func isAppleRsync(_ executable: String) -> Bool { + executable == "/usr/bin/rsync" + } + + private static func normalizeSource(_ path: String) -> String { + path + } + + static func shellEscape(_ value: String) -> String { + if value.isEmpty { return "''" } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./:@%+=,")) + if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { return value } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func shellSplit(_ string: String) -> [String] { + var result: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + for ch in string { + if escaping { + current.append(ch) + escaping = false + continue + } + if ch == "\\" { + escaping = true + continue + } + if let q = quote { + if ch == q { quote = nil } else { current.append(ch) } + continue + } + if ch == "\"" || ch == "'" { + quote = ch + } else if ch.isWhitespace { + if !current.isEmpty { + result.append(current) + current = "" + } + } else { + current.append(ch) + } + } + if !current.isEmpty { result.append(current) } + return result + } +} diff --git a/RsyncManager/Services/RsyncLocator.swift b/RsyncManager/Services/RsyncLocator.swift new file mode 100644 index 0000000..5b9b8f4 --- /dev/null +++ b/RsyncManager/Services/RsyncLocator.swift @@ -0,0 +1,12 @@ +import Foundation + +enum RsyncLocator { + static func locate() -> String { + let candidates = [ + "/opt/homebrew/bin/rsync", + "/usr/local/bin/rsync", + "/usr/bin/rsync" + ] + return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) ?? "/usr/bin/rsync" + } +} diff --git a/RsyncManager/Services/RsyncRunner.swift b/RsyncManager/Services/RsyncRunner.swift new file mode 100644 index 0000000..ba93d8d --- /dev/null +++ b/RsyncManager/Services/RsyncRunner.swift @@ -0,0 +1,139 @@ +import Foundation + +@MainActor +final class RsyncRunner: ObservableObject { + @Published private(set) var isRunning = false + @Published private(set) var currentJobID: UUID? + + private var process: Process? + + struct Result { + let exitCode: Int32 + let stdout: String + let stderr: String + let wasCancelled: Bool + } + + func run(job: SyncJob, dryRun: Bool) async throws -> Result { + guard !isRunning else { throw RunnerError.alreadyRunning } + guard !job.sourcePath.isEmpty, !job.destinationPath.isEmpty else { throw RunnerError.invalidPaths } + if job.isRemote && job.remoteHost.isEmpty { throw RunnerError.missingRemoteHost } + if job.isRemote && remoteDestinationLooksLikeHost(job) { throw RunnerError.invalidRemoteDestination } + if job.isRemote && job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".pub") { + let privatePath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) + if privatePath.hasSuffix(".pub") { throw RunnerError.publicSSHKeySelected } + } + + let executable = RsyncLocator.locate() + let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable) + + isRunning = true + currentJobID = job.id + + defer { + isRunning = false + currentJobID = nil + process = nil + } + + let process = Process() + self.process = process + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // Keep rsync's potentially very chatty output off the MainActor. Updating a + // SwiftUI String for every rsync progress chunk makes the whole window jerk. + // These locked buffers collect every byte for history/export without causing + // any UI invalidation while the transfer is running. + let stdoutBuffer = LockedDataBuffer() + let stderrBuffer = LockedDataBuffer() + + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + stdoutBuffer.append(data) + } + + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + stderrBuffer.append(data) + } + + return try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { proc in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + + // Consume anything left between the final readability callback and EOF. + let remainingOut = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let remainingErr = stderrPipe.fileHandleForReading.readDataToEndOfFile() + if !remainingOut.isEmpty { stdoutBuffer.append(remainingOut) } + if !remainingErr.isEmpty { stderrBuffer.append(remainingErr) } + + continuation.resume(returning: Result( + exitCode: proc.terminationStatus, + stdout: stdoutBuffer.stringValue, + stderr: stderrBuffer.stringValue, + wasCancelled: proc.terminationReason == .uncaughtSignal + )) + } + + do { + try process.run() + } catch { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + continuation.resume(throwing: error) + } + } + } + + private func remoteDestinationLooksLikeHost(_ job: SyncJob) -> Bool { + let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) + return destination == host || destination == "\(job.remoteUser)@\(host)" + } + + func cancel() { + guard let process, process.isRunning else { return } + process.interrupt() + } + + enum RunnerError: LocalizedError { + case alreadyRunning, invalidPaths, missingRemoteHost, invalidRemoteDestination, publicSSHKeySelected + + var errorDescription: String? { + switch self { + case .alreadyRunning: return "Another rsync job is already running." + case .invalidPaths: return "Source and destination are required." + case .missingRemoteHost: return "Remote host is required for an SSH job." + case .invalidRemoteDestination: return "Remote destination must be a path on the remote machine, for example /home/mag/backup or ~/backup — not the host address." + case .publicSSHKeySelected: return "The SSH key must be the private key (for example ~/.ssh/id_rsa or ~/.ssh/id_ed25519), not the .pub file." + } + } + } +} + +private final class LockedDataBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ newData: Data) { + lock.lock() + data.append(newData) + lock.unlock() + } + + var stringValue: String { + lock.lock() + let snapshot = data + lock.unlock() + return String(decoding: snapshot, as: UTF8.self) + } +} diff --git a/RsyncManager/SettingsView.swift b/RsyncManager/SettingsView.swift new file mode 100644 index 0000000..b79f1b1 --- /dev/null +++ b/RsyncManager/SettingsView.swift @@ -0,0 +1,140 @@ +import SwiftUI +import ServiceManagement + +struct SettingsView: View { + @EnvironmentObject private var automation: BackgroundSyncManager + @AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName() + @AppStorage("defaultSSHKeyPath") private var defaultSSHKeyPath = "" + @AppStorage("defaultSSHPort") private var defaultSSHPort = 22 + @AppStorage("defaultRemoteHost") private var defaultRemoteHost = "" + @AppStorage("backgroundSchedulesEnabled") private var backgroundSchedulesEnabled = true + @AppStorage("fileWatchingEnabled") private var fileWatchingEnabled = true + @AppStorage("defaultWatchDebounceSeconds") private var defaultWatchDebounceSeconds = 3 + @State private var defaultPassword = "" + @State private var passwordSaved = false + @State private var launchAtLogin = false + @State private var launchError: String? + + var body: some View { + TabView { + Form { + Section("Default SSH Connection") { + TextField("Username", text: $defaultSSHUsername, prompt: Text(NSUserName())) + TextField("Default host", text: $defaultRemoteHost, prompt: Text("server.local")) + Stepper("SSH port: \(defaultSSHPort)", value: $defaultSSHPort, in: 1...65535) + + HStack { + TextField("Private SSH key", text: $defaultSSHKeyPath, prompt: Text("~/.ssh/id_ed25519")) + Button("Choose…") { + if let selected = FilePicker.chooseFile() { defaultSSHKeyPath = selected } + } + } + + SecureField("Default password", text: $defaultPassword) + HStack { + Text("Stored securely in the macOS Keychain, never in SwiftData or UserDefaults.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(passwordSaved ? "Saved" : "Save Password") { + KeychainStore.set(defaultPassword, for: "defaultSSHPassword") + passwordSaved = true + } + .disabled(defaultPassword.isEmpty) + } + } + + Section("New Jobs") { + Text("New SSH jobs use these values as defaults and can override them independently.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .tabItem { Label("Connections", systemImage: "network") } + + Form { + Section("Background Scheduling") { + Toggle("Run scheduled jobs", isOn: $backgroundSchedulesEnabled) + Toggle("Launch Rsync Manager at login", isOn: Binding( + get: { launchAtLogin }, + set: { setLaunchAtLogin($0) } + )) + HStack { + Label("Configured jobs", systemImage: "calendar.badge.clock") + Spacer() + Text("\(automation.scheduledJobCount)") + .foregroundStyle(.secondary) + } + Text("Scheduled jobs run without opening a modal and are written to History. Rsync Manager continues processing them while its window is hidden, minimised or closed, provided the application itself remains running.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("File Watching") { + Toggle("Watch enabled jobs for changes", isOn: $fileWatchingEnabled) + Stepper("Default debounce: \(defaultWatchDebounceSeconds) seconds", value: $defaultWatchDebounceSeconds, in: 1...60) + HStack { + Label("Watched jobs", systemImage: "eye.fill") + Spacer() + Text("\(automation.watchedJobCount)") + .foregroundStyle(.secondary) + } + Text("Rsync Manager uses macOS FSEvents, so watched trees do not need to be repeatedly scanned. A short debounce combines rapid saves into a single sync.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .tabItem { Label("Automation", systemImage: "calendar.badge.clock") } + + Form { + Section("Rsync") { + LabeledContent("Detected executable", value: RsyncLocator.locate()) + Text("Rsync Manager prefers Homebrew rsync, then /usr/local/bin/rsync, and finally the version supplied with macOS.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Application") { + LabeledContent("Bundle identifier", value: Bundle.main.bundleIdentifier ?? "net.absdev.RsyncManager") + LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") + LabeledContent("Background service", value: automation.activity.title) + } + } + .formStyle(.grouped) + .tabItem { Label("General", systemImage: "gearshape") } + } + .frame(width: 650, height: 455) + .onAppear { + defaultPassword = KeychainStore.string(for: "defaultSSHPassword") + passwordSaved = !defaultPassword.isEmpty + launchAtLogin = SMAppService.mainApp.status == .enabled + } + .onChange(of: defaultPassword) { _, _ in passwordSaved = false } + .onChange(of: backgroundSchedulesEnabled) { _, _ in automation.refreshConfiguration() } + .onChange(of: fileWatchingEnabled) { _, _ in automation.refreshConfiguration() } + .alert("Launch at Login", isPresented: Binding( + get: { launchError != nil }, + set: { if !$0 { launchError = nil } } + )) { + Button("OK", role: .cancel) { launchError = nil } + } message: { + Text(launchError ?? "Unable to change the login item setting.") + } + } + + private func setLaunchAtLogin(_ enabled: Bool) { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + launchAtLogin = SMAppService.mainApp.status == .enabled + } catch { + launchAtLogin = SMAppService.mainApp.status == .enabled + launchError = error.localizedDescription + } + } +} diff --git a/RsyncManager/Utilities/FilePicker.swift b/RsyncManager/Utilities/FilePicker.swift new file mode 100644 index 0000000..4d19593 --- /dev/null +++ b/RsyncManager/Utilities/FilePicker.swift @@ -0,0 +1,21 @@ +import AppKit + +@MainActor +enum FilePicker { + static func chooseDirectory() -> String? { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + return panel.runModal() == .OK ? panel.url?.path : nil + } + + static func chooseFile() -> String? { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + return panel.runModal() == .OK ? panel.url?.path : nil + } +} diff --git a/RsyncManager/Utilities/KeychainStore.swift b/RsyncManager/Utilities/KeychainStore.swift new file mode 100644 index 0000000..e6275b1 --- /dev/null +++ b/RsyncManager/Utilities/KeychainStore.swift @@ -0,0 +1,41 @@ +import Foundation +import Security + +enum KeychainStore { + private static let service = "net.absdev.RsyncManager" + + static func string(for account: String) -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { return "" } + return value + } + + static func set(_ value: String, for account: String) { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + if value.isEmpty { + SecItemDelete(base as CFDictionary) + return + } + let data = Data(value.utf8) + let update: [String: Any] = [kSecValueData as String: data] + if SecItemUpdate(base as CFDictionary, update as CFDictionary) == errSecItemNotFound { + var add = base + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(add as CFDictionary, nil) + } + } +} diff --git a/RsyncManager/Views/Components/FlowLayout.swift b/RsyncManager/Views/Components/FlowLayout.swift new file mode 100644 index 0000000..cc6d259 --- /dev/null +++ b/RsyncManager/Views/Components/FlowLayout.swift @@ -0,0 +1,36 @@ +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) + } + } +} diff --git a/RsyncManager/Views/Components/PathField.swift b/RsyncManager/Views/Components/PathField.swift new file mode 100644 index 0000000..f39100b --- /dev/null +++ b/RsyncManager/Views/Components/PathField.swift @@ -0,0 +1,22 @@ +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 } + } + } + } + } + } +} diff --git a/RsyncManager/Views/Components/SplashView.swift b/RsyncManager/Views/Components/SplashView.swift new file mode 100644 index 0000000..87c9b09 --- /dev/null +++ b/RsyncManager/Views/Components/SplashView.swift @@ -0,0 +1,49 @@ +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 Manager") + .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 + } + } + } +} diff --git a/RsyncManager/Views/Components/StatusBadge.swift b/RsyncManager/Views/Components/StatusBadge.swift new file mode 100644 index 0000000..bd5171e --- /dev/null +++ b/RsyncManager/Views/Components/StatusBadge.swift @@ -0,0 +1,25 @@ +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" + } +} diff --git a/RsyncManager/Views/HelpView.swift b/RsyncManager/Views/HelpView.swift new file mode 100644 index 0000000..f53f0e1 --- /dev/null +++ b/RsyncManager/Views/HelpView.swift @@ -0,0 +1,220 @@ +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 Manager 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: "RsyncManagerHelp", 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[.., + 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 { + Binding( + get: { job.scheduleEnabled }, + set: { enabled in + 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 { + Binding( + get: { job.watchEnabled }, + set: { enabled in + 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() + try? modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + } + + @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) + try? modelContext.save() + + if presentation.showsModal { + presentedRun = run + showingRunSheet = true + } + + Task { + do { + let result = try await runner.run(job: job, dryRun: dryRun) + run.output = result.stdout + run.errorOutput = 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() + try? modelContext.save() + } catch { + run.errorOutput = error.localizedDescription + run.exitCode = -1 + run.finishedAt = Date() + job.lastRunAt = run.finishedAt + job.lastExitCode = -1 + try? modelContext.save() + + 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" + } +} diff --git a/RsyncManager/Views/Jobs/JobEditorView.swift b/RsyncManager/Views/Jobs/JobEditorView.swift new file mode 100644 index 0000000..7e7b541 --- /dev/null +++ b/RsyncManager/Views/Jobs/JobEditorView.swift @@ -0,0 +1,271 @@ +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 + + 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)) + } + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + Form { + Section("Job") { + TextField("Name", text: $name) + Toggle("Enabled", isOn: $isEnabled) + } + + Section("Locations") { + PathField(title: "Source", path: $sourcePath) + Picker("Destination type", selection: $isRemote) { + Text("Local / Mounted Volume").tag(false) + Text("SSH / Remote Mac or Server").tag(true) + } + .pickerStyle(.segmented) + + if isRemote { + TextField("Remote path", text: $destinationPath, prompt: Text("/home/mag/backup or ~/backup")) + .textFieldStyle(.roundedBorder) + } else { + PathField(title: "Destination", path: $destinationPath) + } + } + + if isRemote { + Section("SSH") { + TextField("Host", text: $remoteHost, prompt: Text("pegasus.local")) + TextField("User", text: $remoteUser, prompt: Text(defaultSSHUsername.isEmpty ? NSUserName() : defaultSSHUsername)) + Stepper("Port: \(remotePort)", value: $remotePort, in: 1...65535) + HStack { + TextField("Private SSH key", text: $sshKeyPath, prompt: Text("~/.ssh/id_ed25519 or ~/.ssh/id_rsa")) + Button("Choose…") { + if let selected = FilePicker.chooseFile() { sshKeyPath = selected } + } + } + if sshKeyPath.hasSuffix(".pub") { + Label("Select the private key, not the .pub public key.", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + Section { + Toggle("Run this job on a schedule", isOn: $scheduleEnabled) + if scheduleEnabled { + Picker("Frequency", selection: $scheduleKind) { + ForEach(SyncScheduleKind.allCases.filter { $0 != .manual }) { kind in + Label(kind.title, systemImage: kind.systemImage).tag(kind) + } + } + + DatePicker( + scheduleKind == .once ? "Run date and time" : "Starting", + selection: $scheduleStartAt, + displayedComponents: scheduleKind == .once ? [.date, .hourAndMinute] : [.date, .hourAndMinute] + ) + + if scheduleKind == .custom { + Stepper("Every \(scheduleIntervalMinutes) minutes", value: $scheduleIntervalMinutes, in: 1...525600) + } + + Label(schedulePreview, systemImage: "calendar.badge.clock") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Schedule") + } footer: { + Text("Scheduled jobs run silently and are recorded in History. They continue while Rsync Manager is running, even when its window is in the background.") + } + .id(JobEditorSection.schedule) + + Section { + Toggle("Watch for file changes", isOn: $watchEnabled) + if watchEnabled { + HStack { + TextField("Watch path", text: $watchPath, prompt: Text(sourcePath.isEmpty ? "Uses the job source" : sourcePath)) + Menu("Choose…") { + Button("Choose Folder…") { + if let selected = FilePicker.chooseDirectory() { watchPath = selected } + } + Button("Choose File…") { + if let selected = FilePicker.chooseFile() { watchPath = selected } + } + } + } + Stepper("Wait \(watchDebounceSeconds) seconds after changes", value: $watchDebounceSeconds, in: 1...60) + Label("Changes are monitored with macOS FSEvents. Bursts of edits are combined into one sync.", systemImage: "waveform.path.ecg") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("File Watching") + } footer: { + Text("Leave the watch path empty to watch the source. If the source is a single file, only that file is synced; if it is a folder, the configured folder job runs when anything inside changes.") + } + .id(JobEditorSection.fileWatch) + + Section("Rsync Options") { + Toggle("Archive mode (-a)", isOn: $archive) + Toggle("Compress during transfer (-z)", isOn: $compress) + Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous) + 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)) + } + } + .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) } + } + } + } + .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.updatedAt = Date() + if existingJob == nil { modelContext.insert(job) } + try? modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + dismiss() + } +} diff --git a/RsyncManager/Views/Jobs/JobListView.swift b/RsyncManager/Views/Jobs/JobListView.swift new file mode 100644 index 0000000..d08525f --- /dev/null +++ b/RsyncManager/Views/Jobs/JobListView.swift @@ -0,0 +1,352 @@ +import SwiftUI +import SwiftData + +struct JobListView: View { + @Environment(\.modelContext) private var modelContext + @EnvironmentObject private var automation: BackgroundSyncManager + @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 + + 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) + } + } + } + + 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 + + Button { + showingNewJob = true + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderedProminent) + .help("New Sync Job") + } + + HStack(spacing: 8) { + TextField("Search jobs", text: $searchText) + .textFieldStyle(.roundedBorder) + + Button { + automation.syncAllEnabledJobs() + } label: { + Label("Sync All", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.bordered) + .disabled(jobs.allSatisfy { !$0.isEnabled } || automation.isSyncAllRunning) + .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) + } + } + .padding(14) + } + + @ViewBuilder + private var statusPill: some View { + 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 + modelContext.insert(copy) + saveAndRefresh() + selection = copy + } + + private func delete(_ job: SyncJob) { + if selection?.id == job.id { selection = nil } + modelContext.delete(job) + saveAndRefresh() + } + + private func saveAndRefresh() { + try? modelContext.save() + NotificationCenter.default.post(name: .syncJobsChanged, object: nil) + } +} + +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" + } + } +} diff --git a/RsyncManager/Views/Runs/RunHistoryView.swift b/RsyncManager/Views/Runs/RunHistoryView.swift new file mode 100644 index 0000000..a865c80 --- /dev/null +++ b/RsyncManager/Views/Runs/RunHistoryView.swift @@ -0,0 +1,119 @@ +import SwiftUI +import SwiftData + +struct RunHistoryView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun] + @State private var selection: SyncRun? + 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() + + Button("Clear", role: .destructive) { + runs.forEach(modelContext.delete) + selection = nil + try? modelContext.save() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(runs.isEmpty) + } + .padding(14) + + Divider() + + if runs.isEmpty { + ContentUnavailableView( + "No Run History", + systemImage: "clock.arrow.circlepath", + description: Text("Completed, failed and background synchronisations will appear here.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(selection: $selection) { + ForEach(runs) { 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) + try? modelContext.save() + } + } + } + } + .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) + } + + 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 + } +} diff --git a/RsyncManager/Views/Runs/RunLogView.swift b/RsyncManager/Views/Runs/RunLogView.swift new file mode 100644 index 0000000..5d80a95 --- /dev/null +++ b/RsyncManager/Views/Runs/RunLogView.swift @@ -0,0 +1,47 @@ +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) + } + } +} diff --git a/RsyncManager/Views/ScheduleCalendarView.swift b/RsyncManager/Views/ScheduleCalendarView.swift new file mode 100644 index 0000000..3b2ffa5 --- /dev/null +++ b/RsyncManager/Views/ScheduleCalendarView.swift @@ -0,0 +1,220 @@ +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(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)" } +}