Initial Commit

This commit is contained in:
Mark Gregory
2026-09-08 14:12:59 +01:00
commit 3b95f53a25
46 changed files with 4334 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+12
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+42
View File
@@ -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.
+158
View File
@@ -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 = "<group>"; };
4D2D059072C1E33E64D7DCDF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView.swift"; sourceTree = "<group>"; };
344231C687A1F4BDC84AE838 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView.swift"; sourceTree = "<group>"; };
13136DBE7B701EB6D0CE478E /* SyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncJob.swift"; sourceTree = "<group>"; };
C7A5B87A4971CCE37EF1B979 /* SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncRun.swift"; sourceTree = "<group>"; };
F618D81AA8D3052C88FDF93F /* RsyncCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncCommandBuilder.swift"; sourceTree = "<group>"; };
1F173223A0A15A648115C2B6 /* RsyncRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncRunner.swift"; sourceTree = "<group>"; };
BE0FFB276373DDA4C5A8E419 /* RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/RsyncLocator.swift"; sourceTree = "<group>"; };
8999F614BA12AF14ACCB5E3D /* FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utilities/FilePicker.swift"; sourceTree = "<group>"; };
CA5919F7BA545585E4369602 /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/StatusBadge.swift"; sourceTree = "<group>"; };
58B5F691C6DE4B3B7224BE4B /* PathField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/PathField.swift"; sourceTree = "<group>"; };
5790F644CCAAF49094D57D68 /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/FlowLayout.swift"; sourceTree = "<group>"; };
BCA4F1E7C15E2FC1DCC85FEE /* JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobEditorView.swift"; sourceTree = "<group>"; };
E2639D73ED1EC5C164BC46AF /* JobListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobListView.swift"; sourceTree = "<group>"; };
002D9E0AA02D4B37F6BB05F6 /* JobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Jobs/JobDetailView.swift"; sourceTree = "<group>"; };
CD52560701DBE102AB8E8B73 /* RunHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Runs/RunHistoryView.swift"; sourceTree = "<group>"; };
F39F11523D2F6BF2FBCD90FF /* RunLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Runs/RunLogView.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
AA2000000000000000000002 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/Components/SplashView.swift"; sourceTree = "<group>"; };
AA2000000000000000000003 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AB2000000000000000000001 /* SyncSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models/SyncSchedule.swift"; sourceTree = "<group>"; };
AB2000000000000000000002 /* FileWatchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/FileWatchService.swift"; sourceTree = "<group>"; };
AB2000000000000000000003 /* BackgroundSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/BackgroundSyncManager.swift"; sourceTree = "<group>"; };
AB2000000000000000000004 /* ScheduleCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/ScheduleCalendarView.swift"; sourceTree = "<group>"; };
AC2000000000000000000001 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views/HelpView.swift"; sourceTree = "<group>"; };
AC2000000000000000000002 /* RsyncManagerHelp.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Resources/RsyncManagerHelp.md"; sourceTree = "<group>"; };
/* 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 = "<group>"; };
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 = "<group>"; };
86024CAD1E83101D97359D73 /* Products */ = {isa = PBXGroup; children = (D91517BFBDECE389497A425B /* RsyncManager.app */); name = Products; sourceTree = "<group>"; };
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>RsyncManager.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -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
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
+214
View File
@@ -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
}
}
}
}
+90
View File
@@ -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
}
}
+129
View File
@@ -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
}
}
+142
View File
@@ -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)
}
}
+513
View File
@@ -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.
+64
View File
@@ -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])
}
}
}
@@ -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<UUID>()
private var isProcessingQueue = false
private var watchDebounceTasks: [UUID: Task<Void, Never>] = [:]
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<SyncJob>(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")
}
@@ -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<FileWatchService>.fromOpaque(info).takeUnretainedValue()
let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? []
for index in 0..<min(count, paths.count) {
service.handle(path: paths[index])
}
}
let flags = FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
guard let stream = FSEventStreamCreate(
nil,
callback,
&context,
paths as CFArray,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.4,
flags
) else { return }
self.stream = stream
FSEventStreamSetDispatchQueue(stream, queue)
FSEventStreamStart(stream)
}
private func handle(path: String) {
let normalized = (path as NSString).standardizingPath
for (jobID, target) in watchedTargets {
let targetPath = (target.targetPath as NSString).standardizingPath
let matches = target.isDirectory
? (normalized == targetPath || normalized.hasPrefix(targetPath + "/"))
: normalized == targetPath
if matches { onChange?(jobID, normalized) }
}
}
}
@@ -0,0 +1,132 @@
import Foundation
struct RsyncCommandBuilder {
static func build(job: SyncJob, dryRun: Bool, executable: String) -> [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
}
}
+12
View File
@@ -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"
}
}
+139
View File
@@ -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)
}
}
+140
View File
@@ -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
}
}
}
+21
View File
@@ -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
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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 }
}
}
}
}
}
}
@@ -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
}
}
}
}
@@ -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"
}
}
+220
View File
@@ -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[..<dot]
guard let number = Int(numberText) else { return nil }
let afterDot = line.index(after: dot)
guard afterDot < line.endIndex, line[afterDot] == " " else { return nil }
let textStart = line.index(after: afterDot)
return (number, String(line[textStart...]))
}
}
+670
View File
@@ -0,0 +1,670 @@
import SwiftUI
import SwiftData
import AppKit
struct JobDetailView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject private var runner: RsyncRunner
let job: SyncJob
@State private var showingEditor = false
@State private var editorSection: JobEditorSection?
@State private var errorMessage: String?
@State private var showingDeleteWarning = false
@State private var showingRunSheet = false
@State private var presentedRun: SyncRun?
var body: some View {
VStack(spacing: 0) {
jobHeader
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
locationsSection
automationSection
optionsSection
if job.deleteExtraneous {
Label("Delete mode is enabled. Destination files that are not present at the source can be removed.", systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
HStack(spacing: 10) {
Button {
launch(dryRun: true, presentation: .manual)
} label: {
Label("Dry Run", systemImage: "doc.text.magnifyingglass")
}
.disabled(runner.isRunning || !job.isEnabled)
Button {
if job.deleteExtraneous {
showingDeleteWarning = true
} else {
launch(dryRun: false, presentation: .manual)
}
} label: {
Label("Run Now", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(runner.isRunning || !job.isEnabled)
if runner.isRunning && runner.currentJobID == job.id {
AnimatedSyncIndicator(size: 18, lineWidth: 2.6)
.help("Synchronising this job")
}
Spacer()
}
}
.padding(24)
.frame(maxWidth: 1050, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.sheet(isPresented: $showingEditor) {
JobEditorView(job: job, initialSection: editorSection)
}
.sheet(isPresented: $showingRunSheet) {
if let presentedRun {
RunProgressSheet(run: presentedRun, job: job)
.environmentObject(runner)
}
}
.alert("Run with --delete?", isPresented: $showingDeleteWarning) {
Button("Cancel", role: .cancel) {}
Button("Run", role: .destructive) {
launch(dryRun: false, presentation: .manual)
}
} message: {
Text("This job may delete files from the destination. Consider running a Dry Run first.")
}
.alert("Rsync Error", isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK", role: .cancel) { errorMessage = nil }
} message: {
Text(errorMessage ?? "Unknown error")
}
}
private var jobHeader: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(job.name)
.font(.title2.bold())
.lineLimit(1)
.truncationMode(.tail)
if !job.isEnabled {
Text("Disabled")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.orange.opacity(0.10), in: Capsule())
}
}
Label(job.isRemote ? "SSH remote sync" : "Local sync", systemImage: job.isRemote ? "network" : "internaldrive")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer(minLength: 16)
Button {
openEditor(nil)
} label: {
Label("Edit Job", systemImage: "pencil")
}
.buttonStyle(.borderedProminent)
.keyboardShortcut("e", modifiers: [.command])
}
.padding(.horizontal, 24)
.frame(height: 86)
.background(Color(nsColor: .controlBackgroundColor))
}
private var locationsSection: some View {
GroupBox("Locations") {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) {
GridRow {
Text("Source").foregroundStyle(.secondary)
Text(job.sourcePath).textSelection(.enabled)
}
GridRow {
Text("Destination").foregroundStyle(.secondary)
Text(job.destinationDisplay).textSelection(.enabled)
}
if job.isRemote {
GridRow {
Text("SSH").foregroundStyle(.secondary)
Text("Port \(job.remotePort)")
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 3)
}
}
private var automationSection: some View {
GroupBox("Automation") {
VStack(spacing: 0) {
automationRow(
title: "Schedule",
subtitle: job.scheduleEnabled ? job.scheduleDescription : "Manual only",
icon: "calendar.badge.clock",
colour: .blue,
isOn: scheduleBinding,
buttonTitle: job.scheduleEnabled ? "Edit" : "Configure"
) {
openEditor(.schedule)
}
Divider().padding(.leading, 46)
automationRow(
title: "File Watch",
subtitle: watchSubtitle,
icon: "eye.fill",
colour: .purple,
isOn: watchBinding,
buttonTitle: job.watchEnabled ? "Edit" : "Configure"
) {
openEditor(.fileWatch)
}
}
.padding(.vertical, 2)
}
}
private func automationRow(
title: String,
subtitle: String,
icon: String,
colour: Color,
isOn: Binding<Bool>,
buttonTitle: String,
action: @escaping () -> Void
) -> some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(colour.opacity(0.12))
Image(systemName: icon)
.foregroundStyle(colour)
}
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.body.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 12)
Toggle("", isOn: isOn)
.labelsHidden()
.toggleStyle(.switch)
Button(buttonTitle, action: action)
.buttonStyle(.bordered)
.controlSize(.small)
.frame(width: 88)
}
.padding(.vertical, 10)
}
private var watchSubtitle: String {
guard job.watchEnabled else { return "Off" }
let path = job.effectiveWatchPath
return "Watching \(path) · \(job.watchDebounceSeconds)s debounce"
}
private var optionsSection: some View {
GroupBox("Options") {
FlowLayout(spacing: 8) {
option("Archive", job.archive)
option("Compress", job.compress)
option("Delete", job.deleteExtraneous)
option("Permissions", job.preservePermissions)
option("Extended Attributes", job.preserveExtendedAttributes)
option("Progress", job.showProgress)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 3)
}
}
private var scheduleBinding: Binding<Bool> {
Binding(
get: { job.scheduleEnabled },
set: { enabled in
job.scheduleEnabled = enabled
if enabled {
if job.scheduleKind == .manual { job.scheduleKind = .daily }
if job.scheduleStartAt < Date() { job.scheduleStartAt = Date().addingTimeInterval(3600) }
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
} else {
job.nextRunAt = nil
}
persistAutomationChange()
}
)
}
private var watchBinding: Binding<Bool> {
Binding(
get: { job.watchEnabled },
set: { enabled in
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"
}
}
+271
View File
@@ -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()
}
}
+352
View File
@@ -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"
}
}
}
@@ -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
}
}
+47
View File
@@ -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)
}
}
}
@@ -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<Date?>(repeating: nil, count: leading)
for day in days {
if let date = calendar.date(byAdding: .day, value: day - 1, to: interval.start) { cells.append(date) }
}
while cells.count % 7 != 0 { cells.append(nil) }
return cells
}
private var monthOccurrences: [CalendarOccurrence] {
scheduledJobs.flatMap { job in
job.occurrences(from: monthInterval.start, through: monthInterval.end).map { CalendarOccurrence(job: job, date: $0) }
}.sorted { $0.date < $1.date }
}
private var scheduledJobs: [SyncJob] {
jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }
}
private func occurrences(on day: Date) -> [CalendarOccurrence] {
monthOccurrences.filter { calendar.isDate($0.date, inSameDayAs: day) }
}
private func moveMonth(_ offset: Int) {
if let next = calendar.date(byAdding: .month, value: offset, to: displayedMonth) {
displayedMonth = next
selectedDay = next
}
}
}
private struct CalendarOccurrence: Identifiable {
let job: SyncJob
let date: Date
var id: String { "\(job.id.uuidString)-\(date.timeIntervalSince1970)" }
}