Major update for stability
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Rsync Manager — Security / Release Audit
|
||||
|
||||
This project targets direct macOS distribution using Developer ID signing and Apple notarisation. App Sandbox is intentionally disabled because the product launches `rsync`/SSH and operates on arbitrary user-configured filesystem paths.
|
||||
|
||||
## Security controls present
|
||||
|
||||
- Hardened Runtime enabled for Debug and Release target configurations.
|
||||
- No shell (`/bin/sh -c`) command construction; `Process` uses an absolute executable path and argument array.
|
||||
- SSH host-key checking is not disabled.
|
||||
- Automated SSH uses BatchMode, timeout and keepalive limits.
|
||||
- SSH password authentication/storage is not supported; legacy stored password data is deleted.
|
||||
- Remote host, username, port, key path and remote destination are validated.
|
||||
- Remote paths reject shell metacharacters before rsync can invoke the remote shell.
|
||||
- Advanced arguments reject transport replacement, remote-command, credential, arbitrary list/log/batch path and destructive overrides.
|
||||
- Malformed Advanced argument quoting/trailing escapes are rejected.
|
||||
- `--` terminates rsync option parsing before source/destination operands.
|
||||
- Local source/destination overlap is rejected.
|
||||
- File-watch and exclusion values reject control characters.
|
||||
- Scheduled/watch delete requires explicit per-job authorisation.
|
||||
- Run logs are ANSI-cleaned, likely credentials redacted and size-capped before persistence.
|
||||
- Run history has bounded retention.
|
||||
- Privacy manifest declares no tracking/no collected data and the app-specific UserDefaults required reason.
|
||||
- Launch at login uses `SMAppService` and remains user-controlled.
|
||||
- No analytics/telemetry or application HTTP client code is present.
|
||||
|
||||
## Required release verification on a Mac
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./Scripts/release-preflight.sh
|
||||
```
|
||||
|
||||
Then archive with Developer ID, submit with Apple `notarytool` (or Xcode Organizer), staple the ticket and run the signature/Gatekeeper checks in `Docs/RELEASE.md`.
|
||||
|
||||
A Linux source audit cannot substitute for Xcode's macOS SDK typecheck, code signing, notarisation or Gatekeeper assessment.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Rsync Manager — Direct Distribution Release
|
||||
|
||||
Rsync Manager is designed for direct macOS distribution rather than the Mac App Store because it intentionally launches `rsync`/`ssh` and accesses user-selected arbitrary paths.
|
||||
|
||||
## Release security baseline
|
||||
|
||||
- Developer ID Application signing.
|
||||
- Hardened Runtime enabled.
|
||||
- App Sandbox intentionally disabled for this product architecture.
|
||||
- No Hardened Runtime exception entitlements.
|
||||
- SSH host-key verification is never disabled.
|
||||
- SSH uses BatchMode, connection timeout and keepalive limits.
|
||||
- Password storage/authentication is not supported; use a private key or the macOS SSH agent.
|
||||
- Advanced rsync arguments that can replace the SSH transport, run remote commands, load passwords, read/write arbitrary option/list/log/batch files or bypass controlled delete handling are rejected.
|
||||
- Scheduled/file-watch `--delete` requires explicit per-job authorisation.
|
||||
- Run logs are sanitised and capped before storage and expire according to Settings.
|
||||
- `PrivacyInfo.xcprivacy` declares no tracking/no collected data and the UserDefaults required-reason API.
|
||||
|
||||
## Mandatory preflight
|
||||
|
||||
Before every release archive, run on the release Mac:
|
||||
|
||||
```bash
|
||||
./Scripts/release-preflight.sh
|
||||
```
|
||||
|
||||
This performs the static security policy checks, validates the privacy manifest, and performs a clean unsigned **Release** build with the installed macOS SDK so compiler/typecheck warnings or SDK incompatibilities are caught before signing.
|
||||
|
||||
## Archive
|
||||
|
||||
In Xcode select **Any Mac (Apple Silicon, Intel)**, choose **Product > Archive**, then distribute using **Developer ID**.
|
||||
|
||||
Alternatively:
|
||||
|
||||
```bash
|
||||
xcodebuild -project RsyncManager.xcodeproj -scheme RsyncManager -configuration Release -archivePath build/RsyncManager.xcarchive archive
|
||||
```
|
||||
|
||||
Choose your Developer ID Application certificate/team in Xcode signing settings before archiving.
|
||||
|
||||
## Verify signature
|
||||
|
||||
```bash
|
||||
codesign --verify --deep --strict --verbose=2 /path/to/RsyncManager.app
|
||||
codesign -d --entitlements :- /path/to/RsyncManager.app
|
||||
```
|
||||
|
||||
The Release build should show Hardened Runtime and should not contain `com.apple.security.get-task-allow`.
|
||||
|
||||
## Notarise
|
||||
|
||||
Store notarisation credentials in a Keychain profile once:
|
||||
|
||||
```bash
|
||||
xcrun notarytool store-credentials "RsyncManagerNotary"
|
||||
```
|
||||
|
||||
Submit a signed ZIP/DMG/PKG:
|
||||
|
||||
```bash
|
||||
ditto -c -k --keepParent /path/to/RsyncManager.app RsyncManager.zip
|
||||
xcrun notarytool submit RsyncManager.zip --keychain-profile "RsyncManagerNotary" --wait
|
||||
```
|
||||
|
||||
Staple and validate:
|
||||
|
||||
```bash
|
||||
xcrun stapler staple /path/to/RsyncManager.app
|
||||
xcrun stapler validate /path/to/RsyncManager.app
|
||||
spctl --assess --type execute --verbose=4 /path/to/RsyncManager.app
|
||||
```
|
||||
|
||||
## Pre-release functional security tests
|
||||
|
||||
1. Local source nested inside destination is rejected.
|
||||
2. Local destination nested inside source is rejected.
|
||||
3. SSH host beginning with `-` is rejected.
|
||||
4. SSH username containing spaces/control characters is rejected.
|
||||
5. A `.pub` key cannot be selected as the effective private key.
|
||||
6. Advanced arguments `-e`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files`, `--delete*` and `-M` are rejected, and malformed quoting is rejected.
|
||||
7. Remote destination shell metacharacters are rejected before rsync is launched.
|
||||
8. Scheduled/watch job using `--delete` cannot run until **Allow unattended delete** is explicitly enabled.
|
||||
9. SSH to a previously unknown host does not silently bypass host-key checking.
|
||||
10. Scheduled SSH job with unavailable authentication fails rather than prompting indefinitely.
|
||||
11. History expiry removes old completed runs according to Settings.
|
||||
12. A failed persistent SwiftData store opens Recovery Mode instead of immediately crashing.
|
||||
13. `Scripts/security-check.sh` passes before every signed build.
|
||||
@@ -40,3 +40,9 @@ macOS 14 or later (SwiftData).
|
||||
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.
|
||||
|
||||
## Production security hardening
|
||||
|
||||
This project targets Developer ID signed/notarised direct distribution, not the Mac App Store. Current hardening includes Hardened Runtime, validated rsync executables, protected advanced rsync arguments, non-interactive SSH with timeouts/keepalives, normal SSH host-key verification, private-key/agent authentication only, unattended `--delete` authorisation, local source/destination overlap protection, sanitised/capped run logs, configurable history retention, a privacy manifest, and persistent-store recovery mode.
|
||||
|
||||
See `Docs/RELEASE.md` and run `Scripts/security-check.sh` before producing a release archive.
|
||||
|
||||
@@ -1,157 +1,383 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {};
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0AB9E3362F1DCE2312221B31 /* Models/SyncRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */; };
|
||||
0BBFC488F7DFF17E315AEEF2 /* Utilities/FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */; };
|
||||
1FB249F959D6825750186C76 /* Views/Runs/RunHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD52560701DBE102AB8E8B73 /* Views/Runs/RunHistoryView.swift */; };
|
||||
205FC2ECD561AE0B6EA8BE73 /* Models/SyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13136DBE7B701EB6D0CE478E /* Models/SyncJob.swift */; };
|
||||
3D5AB53FDA192FBBA66D2CEF /* Views/Components/PathField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */; };
|
||||
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 */; };
|
||||
41CA3E36ADC08AE451912D92 /* Views/Components/FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5790F644CCAAF49094D57D68 /* Views/Components/FlowLayout.swift */; };
|
||||
593DB1791A00690020ADF82D /* Views/Jobs/JobEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */; };
|
||||
7C32064BC9CE29F5116ADE89 /* Views/Jobs/JobListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2639D73ED1EC5C164BC46AF /* Views/Jobs/JobListView.swift */; };
|
||||
87F3C7BF9787C5EF1DFC58CE /* Services/RsyncLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */; };
|
||||
93BD9B27AA1F883F0A05CE3A /* Services/RsyncCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F618D81AA8D3052C88FDF93F /* Services/RsyncCommandBuilder.swift */; };
|
||||
AA1000000000000000000001 /* Utilities/KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000001 /* Utilities/KeychainStore.swift */; };
|
||||
AA1000000000000000000002 /* Views/Components/SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000000000000000000002 /* Views/Components/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 */; };
|
||||
AB1000000000000000000001 /* Models/SyncSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000001 /* Models/SyncSchedule.swift */; };
|
||||
AB1000000000000000000002 /* Services/FileWatchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000002 /* Services/FileWatchService.swift */; };
|
||||
AB1000000000000000000003 /* Services/BackgroundSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000003 /* Services/BackgroundSyncManager.swift */; };
|
||||
AB1000000000000000000004 /* Views/ScheduleCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2000000000000000000004 /* Views/ScheduleCalendarView.swift */; };
|
||||
AC1000000000000000000001 /* Views/HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000001 /* Views/HelpView.swift */; };
|
||||
AC1000000000000000000002 /* Resources/RsyncManagerHelp.md in Resources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000002 /* Resources/RsyncManagerHelp.md */; };
|
||||
AD1000000000000000000001 /* Resources/PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */; };
|
||||
C24299D89D285E985C089C0F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344231C687A1F4BDC84AE838 /* SettingsView.swift */; };
|
||||
CC02106B9AF77855935487F6 /* Services/RsyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */; };
|
||||
D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */; };
|
||||
DF2A7BB149EEC298F6207758 /* Views/Jobs/JobDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002D9E0AA02D4B37F6BB05F6 /* Views/Jobs/JobDetailView.swift */; };
|
||||
F58FB754850EEF340B2E65FE /* Views/Runs/RunLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F11523D2F6BF2FBCD90FF /* Views/Runs/RunLogView.swift */; };
|
||||
FB73BE2A7887F32D512EC848 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2D059072C1E33E64D7DCDF /* ContentView.swift */; };
|
||||
/* 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>"; };
|
||||
002D9E0AA02D4B37F6BB05F6 /* Views/Jobs/JobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobDetailView.swift; sourceTree = "<group>"; };
|
||||
13136DBE7B701EB6D0CE478E /* Models/SyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncJob.swift; sourceTree = "<group>"; };
|
||||
1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncRunner.swift; sourceTree = "<group>"; };
|
||||
2C1944E5F7109545301358AD /* RsyncManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncManagerApp.swift; sourceTree = "<group>"; };
|
||||
344231C687A1F4BDC84AE838 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
4D2D059072C1E33E64D7DCDF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
5790F644CCAAF49094D57D68 /* Views/Components/FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/FlowLayout.swift; sourceTree = "<group>"; };
|
||||
58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/PathField.swift; sourceTree = "<group>"; };
|
||||
8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities/FilePicker.swift; sourceTree = "<group>"; };
|
||||
AA2000000000000000000001 /* Utilities/KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities/KeychainStore.swift; sourceTree = "<group>"; };
|
||||
AA2000000000000000000002 /* Views/Components/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>"; };
|
||||
AB2000000000000000000001 /* Models/SyncSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncSchedule.swift; sourceTree = "<group>"; };
|
||||
AB2000000000000000000002 /* Services/FileWatchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/FileWatchService.swift; sourceTree = "<group>"; };
|
||||
AB2000000000000000000003 /* Services/BackgroundSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/BackgroundSyncManager.swift; sourceTree = "<group>"; };
|
||||
AB2000000000000000000004 /* Views/ScheduleCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/ScheduleCalendarView.swift; sourceTree = "<group>"; };
|
||||
AC2000000000000000000001 /* Views/HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/HelpView.swift; sourceTree = "<group>"; };
|
||||
AC2000000000000000000002 /* Resources/RsyncManagerHelp.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = Resources/RsyncManagerHelp.md; sourceTree = "<group>"; };
|
||||
AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Resources/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobEditorView.swift; sourceTree = "<group>"; };
|
||||
BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncLocator.swift; sourceTree = "<group>"; };
|
||||
C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncRun.swift; sourceTree = "<group>"; };
|
||||
CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Components/StatusBadge.swift; sourceTree = "<group>"; };
|
||||
CD52560701DBE102AB8E8B73 /* Views/Runs/RunHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Runs/RunHistoryView.swift; sourceTree = "<group>"; };
|
||||
D91517BFBDECE389497A425B /* RsyncManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RsyncManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2639D73ED1EC5C164BC46AF /* Views/Jobs/JobListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobListView.swift; sourceTree = "<group>"; };
|
||||
F39F11523D2F6BF2FBCD90FF /* Views/Runs/RunLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Runs/RunLogView.swift; sourceTree = "<group>"; };
|
||||
F618D81AA8D3052C88FDF93F /* Services/RsyncCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncCommandBuilder.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
DAF6AA633395B1B76148E35B /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
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 = (
|
||||
86024CAD1E83101D97359D73 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D91517BFBDECE389497A425B /* RsyncManager.app */,
|
||||
);
|
||||
name = 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 */,
|
||||
13136DBE7B701EB6D0CE478E /* Models/SyncJob.swift */,
|
||||
C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */,
|
||||
AB2000000000000000000001 /* Models/SyncSchedule.swift */,
|
||||
F618D81AA8D3052C88FDF93F /* Services/RsyncCommandBuilder.swift */,
|
||||
1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */,
|
||||
BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */,
|
||||
AB2000000000000000000002 /* Services/FileWatchService.swift */,
|
||||
AB2000000000000000000003 /* Services/BackgroundSyncManager.swift */,
|
||||
8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */,
|
||||
CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */,
|
||||
58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */,
|
||||
5790F644CCAAF49094D57D68 /* Views/Components/FlowLayout.swift */,
|
||||
BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */,
|
||||
E2639D73ED1EC5C164BC46AF /* Views/Jobs/JobListView.swift */,
|
||||
002D9E0AA02D4B37F6BB05F6 /* Views/Jobs/JobDetailView.swift */,
|
||||
CD52560701DBE102AB8E8B73 /* Views/Runs/RunHistoryView.swift */,
|
||||
F39F11523D2F6BF2FBCD90FF /* Views/Runs/RunLogView.swift */,
|
||||
AB2000000000000000000004 /* Views/ScheduleCalendarView.swift */,
|
||||
AC2000000000000000000001 /* Views/HelpView.swift */,
|
||||
AC2000000000000000000002 /* Resources/RsyncManagerHelp.md */,
|
||||
AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */,
|
||||
AA2000000000000000000001 /* Utilities/KeychainStore.swift */,
|
||||
AA2000000000000000000002 /* Views/Components/SplashView.swift */,
|
||||
AA2000000000000000000003 /* Assets.xcassets */,
|
||||
); path = RsyncManager; sourceTree = "<group>"; };
|
||||
86024CAD1E83101D97359D73 /* Products */ = {isa = PBXGroup; children = (D91517BFBDECE389497A425B /* RsyncManager.app */); name = Products; sourceTree = "<group>"; };
|
||||
);
|
||||
path = RsyncManager;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAD58DE7366495DB4650CFEF = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B60F08E1B2CF35AEB2F1DE90 /* RsyncManager */,
|
||||
86024CAD1E83101D97359D73 /* 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"; };
|
||||
42AEFBAE01D2DFD981F7DA7D /* RsyncManager */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1EE5970F9824F2EE5F7679D7 /* Build configuration list for PBXNativeTarget "RsyncManager" */;
|
||||
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 */); };
|
||||
46F86FAA6BBF9AC94A7E4595 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 2700;
|
||||
TargetAttributes = {
|
||||
42AEFBAE01D2DFD981F7DA7D = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F0E5A3A488914D1AA34BF23A /* Build configuration list for PBXProject "RsyncManager" */;
|
||||
compatibilityVersion = "Xcode 15.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = FAD58DE7366495DB4650CFEF;
|
||||
productRefGroup = 86024CAD1E83101D97359D73 /* Products */;
|
||||
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; };
|
||||
B9C6EB6F79FC54535ACB3053 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA1000000000000000000003 /* Assets.xcassets in Resources */,
|
||||
AC1000000000000000000002 /* Resources/RsyncManagerHelp.md in Resources */,
|
||||
AD1000000000000000000001 /* Resources/PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
FCA229C23F166EBB0BA5029E /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (
|
||||
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; };
|
||||
205FC2ECD561AE0B6EA8BE73 /* Models/SyncJob.swift in Sources */,
|
||||
0AB9E3362F1DCE2312221B31 /* Models/SyncRun.swift in Sources */,
|
||||
AB1000000000000000000001 /* Models/SyncSchedule.swift in Sources */,
|
||||
93BD9B27AA1F883F0A05CE3A /* Services/RsyncCommandBuilder.swift in Sources */,
|
||||
CC02106B9AF77855935487F6 /* Services/RsyncRunner.swift in Sources */,
|
||||
87F3C7BF9787C5EF1DFC58CE /* Services/RsyncLocator.swift in Sources */,
|
||||
AB1000000000000000000002 /* Services/FileWatchService.swift in Sources */,
|
||||
AB1000000000000000000003 /* Services/BackgroundSyncManager.swift in Sources */,
|
||||
0BBFC488F7DFF17E315AEEF2 /* Utilities/FilePicker.swift in Sources */,
|
||||
D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */,
|
||||
3D5AB53FDA192FBBA66D2CEF /* Views/Components/PathField.swift in Sources */,
|
||||
41CA3E36ADC08AE451912D92 /* Views/Components/FlowLayout.swift in Sources */,
|
||||
593DB1791A00690020ADF82D /* Views/Jobs/JobEditorView.swift in Sources */,
|
||||
7C32064BC9CE29F5116ADE89 /* Views/Jobs/JobListView.swift in Sources */,
|
||||
DF2A7BB149EEC298F6207758 /* Views/Jobs/JobDetailView.swift in Sources */,
|
||||
1FB249F959D6825750186C76 /* Views/Runs/RunHistoryView.swift in Sources */,
|
||||
F58FB754850EEF340B2E65FE /* Views/Runs/RunLogView.swift in Sources */,
|
||||
AB1000000000000000000004 /* Views/ScheduleCalendarView.swift in Sources */,
|
||||
AC1000000000000000000001 /* Views/HelpView.swift in Sources */,
|
||||
AA1000000000000000000001 /* Utilities/KeychainStore.swift in Sources */,
|
||||
AA1000000000000000000002 /* Views/Components/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; };
|
||||
0C42D4EB6117C2B9C8008C35 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
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;
|
||||
};
|
||||
43E6727D37020658CECE8F0C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
643EE4829B317660C2A7A37C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A969B6690DDF65BACEAA2A89 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
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; };
|
||||
1EE5970F9824F2EE5F7679D7 /* Build configuration list for PBXNativeTarget "RsyncManager" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0C42D4EB6117C2B9C8008C35 /* Debug */,
|
||||
A969B6690DDF65BACEAA2A89 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F0E5A3A488914D1AA34BF23A /* Build configuration list for PBXProject "RsyncManager" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
43E6727D37020658CECE8F0C /* Debug */,
|
||||
643EE4829B317660C2A7A37C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */;
|
||||
|
||||
RsyncManager.xcodeproj/project.xcworkspace/xcuserdata/mag.xcuserdatad/UserInterfaceState.xcuserstate
Generated
BIN
Binary file not shown.
@@ -2,12 +2,18 @@ import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
let startupStorageError: String?
|
||||
@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
|
||||
@State private var showingStorageAlert = false
|
||||
|
||||
init(startupStorageError: String? = nil) {
|
||||
self.startupStorageError = startupStorageError
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -21,6 +27,12 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewJob) { JobEditorView() }
|
||||
.alert("Storage Recovery Mode", isPresented: $showingStorageAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(startupStorageError ?? "Rsync Manager is using temporary storage.")
|
||||
}
|
||||
.onAppear { showingStorageAlert = startupStorageError != nil }
|
||||
.task {
|
||||
guard showingSplash else { return }
|
||||
try? await Task.sleep(for: .seconds(1.0))
|
||||
@@ -124,6 +136,13 @@ struct ContentView: View {
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
}
|
||||
|
||||
if let serviceError = automation.lastError {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Color.orange)
|
||||
.help(serviceError)
|
||||
.accessibilityLabel("Background service error")
|
||||
}
|
||||
|
||||
Picker("Section", selection: $section) {
|
||||
Label("Jobs", systemImage: "rectangle.stack").tag(AppSection.jobs)
|
||||
Label("Calendar", systemImage: "calendar").tag(AppSection.calendar)
|
||||
|
||||
@@ -34,6 +34,7 @@ final class SyncJob {
|
||||
var watchPath: String = ""
|
||||
var watchDebounceSeconds: Int = 3
|
||||
var lastWatchEventAt: Date? = nil
|
||||
var allowUnattendedDelete: Bool = false
|
||||
|
||||
init(
|
||||
name: String = "New Sync Job",
|
||||
@@ -71,6 +72,7 @@ final class SyncJob {
|
||||
self.watchPath = ""
|
||||
self.watchDebounceSeconds = 3
|
||||
self.lastWatchEventAt = nil
|
||||
self.allowUnattendedDelete = false
|
||||
}
|
||||
|
||||
var exclusions: [String] {
|
||||
|
||||
@@ -43,6 +43,27 @@ final class SyncRun {
|
||||
RsyncSummary.parse(output + "\n" + errorOutput)
|
||||
}
|
||||
|
||||
|
||||
static func sanitizedLog(_ value: String, maxBytes: Int = 2_000_000) -> String {
|
||||
let stripped = stripANSI(value)
|
||||
let redacted = stripped
|
||||
.replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression)
|
||||
|
||||
let data = Data(redacted.utf8)
|
||||
guard data.count > maxBytes else { return redacted }
|
||||
let prefix = data.prefix(maxBytes)
|
||||
return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Manager for safety and storage limits.]\n"
|
||||
}
|
||||
|
||||
private static func stripANSI(_ value: String) -> String {
|
||||
value.replacingOccurrences(
|
||||
of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#,
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
|
||||
var exportText: String {
|
||||
var text = "Rsync Manager Run Log\n"
|
||||
text += "Job: \(jobName)\n"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -511,3 +511,41 @@ For an rsync-specific option not documented here, run `man rsync` in Terminal fo
|
||||
|
||||
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.
|
||||
|
||||
|
||||
# Security and unattended operation
|
||||
|
||||
Rsync Manager is designed for direct Developer ID distribution. It uses the macOS Hardened Runtime and intentionally does not use the App Sandbox because the utility must launch `rsync`/`ssh` and work with arbitrary user-selected paths.
|
||||
|
||||
## SSH security
|
||||
|
||||
SSH authentication uses a private key or your normal macOS SSH agent. Rsync Manager does not store SSH passwords and does not place passwords on a process command line. Automated SSH uses `BatchMode=yes`, a connection timeout, keepalive intervals and a finite keepalive failure count. Host-key verification is never disabled; connect to a new host once in Terminal first if SSH needs you to approve its host key.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local
|
||||
```
|
||||
|
||||
## Protected advanced arguments
|
||||
|
||||
The Advanced field is for ordinary rsync tuning only. Rsync Manager rejects arguments that can replace the SSH transport, run a different remote command, read options/file lists from other files, write logs/batches to arbitrary paths, load a password file, enter daemon mode, remove source files or bypass the app's controlled delete switch. Examples that are rejected include `-e`, `-M`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files` and `--delete*`. Unmatched quotes and trailing escape characters are also rejected.
|
||||
|
||||
## Delete protection
|
||||
|
||||
Manual `--delete` runs always show a destructive-operation warning. If a scheduled or file-watch job uses Delete, you must also explicitly enable **Allow unattended delete** in the job editor. This prevents a job from becoming destructively automated merely because Schedule or File Watch was enabled later.
|
||||
|
||||
Run a Dry Run and review the destination before authorising unattended delete.
|
||||
|
||||
## Path overlap protection
|
||||
|
||||
For local jobs, source and destination must be separate directory trees. Rsync Manager rejects configurations where the destination is inside the source or the source is inside the destination. This prevents recursive backup trees and common file-watch loops.
|
||||
|
||||
## History and privacy
|
||||
|
||||
Rsync Manager does not include analytics or tracking. Run output is sanitised before persistence, terminal control sequences are removed, likely credential-shaped values are redacted, and each stdout/stderr log is capped at approximately 2 MB. History retention is configurable in **Settings > Automation > History & Privacy** and defaults to 90 days.
|
||||
|
||||
The bundled privacy manifest declares no tracking and no collected data.
|
||||
|
||||
## Release verification
|
||||
|
||||
The project contains `Docs/RELEASE.md` and `Scripts/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftData
|
||||
@MainActor
|
||||
struct RsyncManagerApp: App {
|
||||
let sharedModelContainer: ModelContainer
|
||||
let startupStorageError: String?
|
||||
@StateObject private var runner: RsyncRunner
|
||||
@StateObject private var automation: BackgroundSyncManager
|
||||
|
||||
@@ -14,20 +15,33 @@ struct RsyncManagerApp: App {
|
||||
SyncRun.self,
|
||||
])
|
||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
KeychainStore.delete("defaultSSHPassword")
|
||||
|
||||
do {
|
||||
let container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
sharedModelContainer = container
|
||||
startupStorageError = nil
|
||||
let rsyncRunner = RsyncRunner()
|
||||
_runner = StateObject(wrappedValue: rsyncRunner)
|
||||
_automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: container.mainContext, runner: rsyncRunner))
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
let fallbackConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
do {
|
||||
let fallback = try ModelContainer(for: schema, configurations: [fallbackConfiguration])
|
||||
sharedModelContainer = fallback
|
||||
startupStorageError = "Rsync Manager could not open its persistent database and is running with temporary in-memory storage for this session. Jobs or history created now will not persist. Original error: \(error.localizedDescription)"
|
||||
let rsyncRunner = RsyncRunner()
|
||||
_runner = StateObject(wrappedValue: rsyncRunner)
|
||||
_automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: fallback.mainContext, runner: rsyncRunner))
|
||||
} catch {
|
||||
fatalError("Rsync Manager could not initialise persistent or temporary storage: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
ContentView(startupStorageError: startupStorageError)
|
||||
.frame(minWidth: 1040, minHeight: 680)
|
||||
.environmentObject(runner)
|
||||
.environmentObject(automation)
|
||||
|
||||
@@ -23,6 +23,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
@Published private(set) var isSyncAllRunning = false
|
||||
@Published private(set) var syncAllCompleted = 0
|
||||
@Published private(set) var syncAllTotal = 0
|
||||
@Published private(set) var lastError: String?
|
||||
|
||||
var activityDisplayTitle: String {
|
||||
if isSyncAllRunning {
|
||||
@@ -67,6 +68,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
refreshConfiguration()
|
||||
pruneHistoryIfNeeded(force: true)
|
||||
checkSchedules()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -119,7 +121,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil {
|
||||
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("refreshing automation configuration")
|
||||
}
|
||||
|
||||
private func checkSchedules() {
|
||||
@@ -136,7 +138,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
job.nextRunAt = job.calculateNextRun(after: now)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("updating scheduled jobs")
|
||||
}
|
||||
|
||||
private func fileChanged(jobID: UUID, path: String) {
|
||||
@@ -150,7 +152,7 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
_ = self.enqueue(jobID: jobID, trigger: .watched)
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("recording file-watch activity")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -209,12 +211,12 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
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()
|
||||
saveContext("creating run history")
|
||||
|
||||
do {
|
||||
let result = try await runner.run(job: job, dryRun: false)
|
||||
run.output = result.stdout
|
||||
run.errorOutput = result.stderr
|
||||
let result = try await runner.run(job: job, dryRun: false, trigger: trigger)
|
||||
run.output = SyncRun.sanitizedLog(result.stdout)
|
||||
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
|
||||
run.exitCode = Int(result.exitCode)
|
||||
run.wasCancelled = result.wasCancelled
|
||||
run.finishedAt = Date()
|
||||
@@ -222,13 +224,42 @@ final class BackgroundSyncManager: ObservableObject {
|
||||
job.lastExitCode = Int(result.exitCode)
|
||||
job.updatedAt = Date()
|
||||
} catch {
|
||||
run.errorOutput = error.localizedDescription
|
||||
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
|
||||
run.exitCode = -1
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = -1
|
||||
}
|
||||
try? modelContext.save()
|
||||
saveContext("saving run history")
|
||||
pruneHistoryIfNeeded()
|
||||
}
|
||||
|
||||
private func pruneHistoryIfNeeded(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force, let last = UserDefaults.standard.object(forKey: "lastHistoryPruneAt") as? Date, now.timeIntervalSince(last) < 3600 { return }
|
||||
|
||||
let configuredDays = UserDefaults.standard.integer(forKey: "historyRetentionDays")
|
||||
let days = configuredDays == 0 ? 90 : max(7, min(configuredDays, 3650))
|
||||
let cutoff = now.addingTimeInterval(-TimeInterval(days) * 86_400)
|
||||
|
||||
do {
|
||||
let runs = try modelContext.fetch(FetchDescriptor<SyncRun>())
|
||||
for run in runs where run.finishedAt != nil && run.startedAt < cutoff {
|
||||
modelContext.delete(run)
|
||||
}
|
||||
try modelContext.save()
|
||||
UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt")
|
||||
} catch {
|
||||
lastError = "Could not prune run history: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContext(_ operation: String) {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
lastError = "Database error while \(operation): \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchJobs() -> [SyncJob] {
|
||||
|
||||
@@ -12,31 +12,16 @@ struct RsyncCommandBuilder {
|
||||
|
||||
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")
|
||||
}
|
||||
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"]
|
||||
@@ -50,14 +35,24 @@ struct RsyncCommandBuilder {
|
||||
}
|
||||
|
||||
if job.isRemote {
|
||||
var sshParts = ["ssh", "-p", String(job.remotePort)]
|
||||
var sshParts = [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=15",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", String(job.remotePort)
|
||||
]
|
||||
let keyPath = normalizedPrivateKeyPath(job.sshKeyPath)
|
||||
if !keyPath.isEmpty {
|
||||
sshParts += ["-i", keyPath]
|
||||
sshParts += ["-o", "IdentitiesOnly=yes", "-i", keyPath]
|
||||
}
|
||||
args += ["-e", sshParts.map(shellEscape).joined(separator: " ")]
|
||||
}
|
||||
|
||||
// End option parsing before user-controlled filesystem operands. This prevents
|
||||
// a local path beginning with '-' from being interpreted as another rsync option.
|
||||
args.append("--")
|
||||
args.append(normalizeSource(job.sourcePath))
|
||||
args.append(job.destinationDisplay)
|
||||
return args
|
||||
@@ -71,22 +66,21 @@ struct RsyncCommandBuilder {
|
||||
|
||||
static func normalizedPrivateKeyPath(_ path: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasSuffix(".pub") else { return trimmed }
|
||||
guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath }
|
||||
|
||||
let privatePath = String(trimmed.dropLast(4))
|
||||
if FileManager.default.fileExists(atPath: privatePath) {
|
||||
return privatePath
|
||||
let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath
|
||||
if FileManager.default.fileExists(atPath: expandedPrivatePath) {
|
||||
return expandedPrivatePath
|
||||
}
|
||||
return trimmed
|
||||
return (trimmed as NSString).expandingTildeInPath
|
||||
}
|
||||
|
||||
private static func isAppleRsync(_ executable: String) -> Bool {
|
||||
executable == "/usr/bin/rsync"
|
||||
}
|
||||
|
||||
private static func normalizeSource(_ path: String) -> String {
|
||||
path
|
||||
}
|
||||
private static func normalizeSource(_ path: String) -> String { path }
|
||||
|
||||
static func shellEscape(_ value: String) -> String {
|
||||
if value.isEmpty { return "''" }
|
||||
@@ -130,3 +124,169 @@ struct RsyncCommandBuilder {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum RsyncSecurityValidator {
|
||||
static func validationMessage(for job: SyncJob, trigger: SyncRunTrigger = .manual) -> String? {
|
||||
let source = job.sourcePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !source.isEmpty, !destination.isEmpty else {
|
||||
return "Source and destination are required."
|
||||
}
|
||||
|
||||
if containsControlCharacters(source) || containsControlCharacters(destination) {
|
||||
return "Paths may not contain control characters."
|
||||
}
|
||||
|
||||
if job.watchEnabled && containsControlCharacters(job.effectiveWatchPath) {
|
||||
return "The file-watch path may not contain control characters."
|
||||
}
|
||||
|
||||
if job.exclusions.contains(where: containsControlCharacters) {
|
||||
return "Exclusion patterns may not contain control characters."
|
||||
}
|
||||
|
||||
if let advancedError = validateAdditionalArguments(job.additionalArguments) {
|
||||
return advancedError
|
||||
}
|
||||
|
||||
if job.isRemote {
|
||||
let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = job.remoteUser.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard isValidHost(host) else {
|
||||
return "The SSH host is invalid. Use a hostname or IP address without spaces or command-line option prefixes."
|
||||
}
|
||||
if !user.isEmpty, !isValidUser(user) {
|
||||
return "The SSH username is invalid. Use letters, numbers, '.', '_' or '-'."
|
||||
}
|
||||
guard (1...65535).contains(job.remotePort) else {
|
||||
return "The SSH port must be between 1 and 65535."
|
||||
}
|
||||
if destination.hasPrefix("-") {
|
||||
return "A remote destination may not begin with '-'."
|
||||
}
|
||||
if !isSafeRemotePath(destination) {
|
||||
return "The remote destination contains characters that are unsafe for an SSH/rsync remote shell. Use a normal path containing letters, numbers, spaces, '/', '.', '_', '-', '~', '+', '@', '%' or '=' only."
|
||||
}
|
||||
if destination == host || destination == "\(user)@\(host)" {
|
||||
return "Remote destination must be a path on the remote machine, not the host address."
|
||||
}
|
||||
|
||||
let keyPath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath)
|
||||
if !job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if keyPath.hasSuffix(".pub") {
|
||||
return "Select the private SSH key, not the .pub public key."
|
||||
}
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: keyPath, isDirectory: &isDirectory), !isDirectory.boolValue else {
|
||||
return "The selected private SSH key does not exist or is not a regular file."
|
||||
}
|
||||
}
|
||||
} else if pathsOverlap(source: source, destination: destination) {
|
||||
return "Source and destination overlap. Choose separate directory trees to prevent recursive copies or file-watch loops."
|
||||
}
|
||||
|
||||
if job.deleteExtraneous,
|
||||
(trigger == .scheduled || trigger == .watched),
|
||||
!job.allowUnattendedDelete {
|
||||
return "This job uses --delete but unattended destructive sync is not authorised. Edit the job and explicitly enable unattended delete."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func validateAdditionalArguments(_ raw: String) -> String? {
|
||||
if !hasBalancedShellSyntax(raw) {
|
||||
return "Advanced rsync arguments contain an unmatched quote or trailing escape character."
|
||||
}
|
||||
|
||||
let arguments = RsyncCommandBuilder.shellSplit(raw)
|
||||
let blockedExact: Set<String> = [
|
||||
"-e", "-M", "--rsh", "--rsync-path", "--remote-option",
|
||||
"--password-file", "--daemon", "--config", "--port", "--address",
|
||||
"--sockopts", "--remove-source-files", "--copy-as", "--files-from",
|
||||
"--exclude-from", "--include-from", "--log-file", "--log-file-format",
|
||||
"--backup-dir", "--temp-dir", "--partial-dir", "--chown", "--usermap",
|
||||
"--groupmap", "--rsync-path", "--write-batch", "--only-write-batch",
|
||||
"--read-batch"
|
||||
]
|
||||
let blockedPrefixes = [
|
||||
"--rsh=", "--rsync-path=", "--remote-option=", "--password-file=",
|
||||
"--config=", "--port=", "--address=", "--sockopts=", "--copy-as=",
|
||||
"--delete", "--remove-source-files", "--files-from=", "--exclude-from=",
|
||||
"--include-from=", "--log-file=", "--log-file-format=", "--backup-dir=",
|
||||
"--temp-dir=", "--partial-dir=", "--chown=", "--usermap=", "--groupmap=",
|
||||
"--write-batch=", "--only-write-batch=", "--read-batch="
|
||||
]
|
||||
|
||||
for argument in arguments {
|
||||
if blockedExact.contains(argument) || blockedPrefixes.contains(where: { argument.hasPrefix($0) }) {
|
||||
return "Advanced argument '\(argument)' is security-sensitive or destructive and is managed by Rsync Manager. Remove it and use the dedicated job controls instead."
|
||||
}
|
||||
if containsControlCharacters(argument) {
|
||||
return "Advanced rsync arguments may not contain control characters."
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
private static func isSafeRemotePath(_ value: String) -> Bool {
|
||||
guard !value.isEmpty, !containsControlCharacters(value) else { return false }
|
||||
// Rsync invokes the remote side through SSH. Keep remote paths out of shell
|
||||
// metacharacter territory so a configured path can never become a remote command.
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " /._-~+@%="))
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func hasBalancedShellSyntax(_ value: String) -> Bool {
|
||||
var quote: Character?
|
||||
var escaping = false
|
||||
for ch in value {
|
||||
if escaping {
|
||||
escaping = false
|
||||
continue
|
||||
}
|
||||
if ch == "\\" {
|
||||
escaping = true
|
||||
continue
|
||||
}
|
||||
if let activeQuote = quote {
|
||||
if ch == activeQuote { quote = nil }
|
||||
continue
|
||||
}
|
||||
if ch == "\"" || ch == "'" { quote = ch }
|
||||
}
|
||||
return quote == nil && !escaping
|
||||
}
|
||||
|
||||
private static func isValidHost(_ value: String) -> Bool {
|
||||
guard !value.isEmpty, !value.hasPrefix("-"), !containsControlCharacters(value) else { return false }
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_:[]"))
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func isValidUser(_ value: String) -> Bool {
|
||||
guard !value.hasPrefix("-"), !containsControlCharacters(value) else { return false }
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func containsControlCharacters(_ value: String) -> Bool {
|
||||
value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) }
|
||||
}
|
||||
|
||||
private static func pathsOverlap(source: String, destination: String) -> Bool {
|
||||
let src = canonicalLocalPath(source)
|
||||
let dst = canonicalLocalPath(destination)
|
||||
guard !src.isEmpty, !dst.isEmpty else { return false }
|
||||
if src == dst { return true }
|
||||
return dst.hasPrefix(src + "/") || src.hasPrefix(dst + "/")
|
||||
}
|
||||
|
||||
private static func canonicalLocalPath(_ value: String) -> String {
|
||||
let expanded = (value as NSString).expandingTildeInPath
|
||||
let standardized = (expanded as NSString).standardizingPath
|
||||
if standardized == "/" { return standardized }
|
||||
return standardized.hasSuffix("/") ? String(standardized.dropLast()) : standardized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
enum RsyncLocator {
|
||||
static let candidates = [
|
||||
"/opt/homebrew/bin/rsync",
|
||||
"/usr/local/bin/rsync",
|
||||
"/usr/bin/rsync"
|
||||
]
|
||||
|
||||
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"
|
||||
candidates.first(where: isTrustedExecutableCandidate) ?? "/usr/bin/rsync"
|
||||
}
|
||||
|
||||
static func isTrustedExecutableCandidate(_ path: String) -> Bool {
|
||||
let manager = FileManager.default
|
||||
guard manager.isExecutableFile(atPath: path) else { return false }
|
||||
|
||||
let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
|
||||
var isDirectory: ObjCBool = false
|
||||
guard manager.fileExists(atPath: resolved, isDirectory: &isDirectory), !isDirectory.boolValue else { return false }
|
||||
|
||||
do {
|
||||
let attributes = try manager.attributesOfItem(atPath: resolved)
|
||||
return attributes[.type] as? FileAttributeType == .typeRegular
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,16 @@ final class RsyncRunner: ObservableObject {
|
||||
let wasCancelled: Bool
|
||||
}
|
||||
|
||||
func run(job: SyncJob, dryRun: Bool) async throws -> Result {
|
||||
func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) 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 }
|
||||
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: trigger) {
|
||||
throw RunnerError.securityValidation(message)
|
||||
}
|
||||
|
||||
let executable = RsyncLocator.locate()
|
||||
guard RsyncLocator.isTrustedExecutableCandidate(executable) else {
|
||||
throw RunnerError.invalidExecutable(executable)
|
||||
}
|
||||
let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable)
|
||||
|
||||
isRunning = true
|
||||
@@ -106,15 +105,18 @@ final class RsyncRunner: ObservableObject {
|
||||
}
|
||||
|
||||
enum RunnerError: LocalizedError {
|
||||
case alreadyRunning, invalidPaths, missingRemoteHost, invalidRemoteDestination, publicSSHKeySelected
|
||||
case alreadyRunning
|
||||
case securityValidation(String)
|
||||
case invalidExecutable(String)
|
||||
|
||||
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."
|
||||
case .alreadyRunning:
|
||||
return "Another rsync job is already running."
|
||||
case .securityValidation(let message):
|
||||
return message
|
||||
case .invalidExecutable(let path):
|
||||
return "The rsync executable at \(path) is unavailable or is not a trusted regular executable file."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ struct SettingsView: View {
|
||||
@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
|
||||
@AppStorage("historyRetentionDays") private var historyRetentionDays = 90
|
||||
@State private var launchAtLogin = false
|
||||
@State private var launchError: String?
|
||||
|
||||
@@ -30,18 +29,9 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Label("Authentication uses an SSH private key or your macOS SSH agent. Passwords are intentionally not stored or passed on the command line.", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("New Jobs") {
|
||||
@@ -71,6 +61,13 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("History & Privacy") {
|
||||
Stepper("Keep run history for \(historyRetentionDays) days", value: $historyRetentionDays, in: 7...3650, step: 7)
|
||||
Text("Run output is sanitised, ANSI control sequences are removed, and individual stdout/stderr logs are capped at approximately 2 MB before storage.")
|
||||
.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)
|
||||
@@ -91,7 +88,16 @@ struct SettingsView: View {
|
||||
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.")
|
||||
LabeledContent("Executable validation", value: RsyncLocator.isTrustedExecutableCandidate(RsyncLocator.locate()) ? "Passed" : "Failed")
|
||||
Text("Only executable regular-file rsync candidates are accepted. Symlinks are resolved before validation.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Security") {
|
||||
LabeledContent("SSH mode", value: "Batch / key or agent")
|
||||
LabeledContent("Host-key verification", value: "System default")
|
||||
Text("SSH connections use BatchMode, connection timeouts and keepalive limits. Rsync Manager never disables SSH host-key verification.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -107,11 +113,9 @@ struct SettingsView: View {
|
||||
}
|
||||
.frame(width: 650, height: 455)
|
||||
.onAppear {
|
||||
defaultPassword = KeychainStore.string(for: "defaultSSHPassword")
|
||||
passwordSaved = !defaultPassword.isEmpty
|
||||
KeychainStore.delete("defaultSSHPassword")
|
||||
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(
|
||||
|
||||
@@ -19,23 +19,37 @@ enum KeychainStore {
|
||||
return value
|
||||
}
|
||||
|
||||
static func set(_ value: String, for account: String) {
|
||||
@discardableResult
|
||||
static func set(_ value: String, for account: String) -> OSStatus {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
if value.isEmpty {
|
||||
SecItemDelete(base as CFDictionary)
|
||||
return
|
||||
}
|
||||
if value.isEmpty { return delete(account) }
|
||||
|
||||
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)
|
||||
}
|
||||
let update: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
||||
]
|
||||
let updateStatus = SecItemUpdate(base as CFDictionary, update as CFDictionary)
|
||||
if updateStatus != errSecItemNotFound { return updateStatus }
|
||||
|
||||
var add = base
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
|
||||
return SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func delete(_ account: String) -> OSStatus {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecItemNotFound ? errSecSuccess : status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,10 @@ struct JobDetailView: View {
|
||||
Binding(
|
||||
get: { job.scheduleEnabled },
|
||||
set: { enabled in
|
||||
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
|
||||
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling."
|
||||
return
|
||||
}
|
||||
job.scheduleEnabled = enabled
|
||||
if enabled {
|
||||
if job.scheduleKind == .manual { job.scheduleKind = .daily }
|
||||
@@ -268,6 +272,10 @@ struct JobDetailView: View {
|
||||
Binding(
|
||||
get: { job.watchEnabled },
|
||||
set: { enabled in
|
||||
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
|
||||
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching."
|
||||
return
|
||||
}
|
||||
job.watchEnabled = enabled
|
||||
if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
job.watchPath = job.sourcePath
|
||||
@@ -284,8 +292,12 @@ struct JobDetailView: View {
|
||||
|
||||
private func persistAutomationChange() {
|
||||
job.updatedAt = Date()
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
do {
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
} catch {
|
||||
errorMessage = "Could not save automation settings: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -316,7 +328,12 @@ struct JobDetailView: View {
|
||||
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()
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
errorMessage = "Could not create run history: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
if presentation.showsModal {
|
||||
presentedRun = run
|
||||
@@ -325,23 +342,25 @@ struct JobDetailView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await runner.run(job: job, dryRun: dryRun)
|
||||
run.output = result.stdout
|
||||
run.errorOutput = result.stderr
|
||||
let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual)
|
||||
run.output = SyncRun.sanitizedLog(result.stdout)
|
||||
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
|
||||
run.exitCode = Int(result.exitCode)
|
||||
run.wasCancelled = result.wasCancelled
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = Int(result.exitCode)
|
||||
job.updatedAt = Date()
|
||||
try? modelContext.save()
|
||||
do { try modelContext.save() }
|
||||
catch { errorMessage = "Could not save run history: \(error.localizedDescription)" }
|
||||
} catch {
|
||||
run.errorOutput = error.localizedDescription
|
||||
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
|
||||
run.exitCode = -1
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = -1
|
||||
try? modelContext.save()
|
||||
do { try modelContext.save() }
|
||||
catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" }
|
||||
|
||||
if !presentation.showsModal {
|
||||
errorMessage = error.localizedDescription
|
||||
|
||||
@@ -38,6 +38,8 @@ struct JobEditorView: View {
|
||||
@State private var watchEnabled: Bool
|
||||
@State private var watchPath: String
|
||||
@State private var watchDebounceSeconds: Int
|
||||
@State private var allowUnattendedDelete: Bool
|
||||
@State private var validationMessage: String?
|
||||
|
||||
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
|
||||
self.existingJob = job
|
||||
@@ -68,6 +70,8 @@ struct JobEditorView: View {
|
||||
_watchPath = State(initialValue: job?.watchPath ?? "")
|
||||
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
|
||||
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
|
||||
_allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false)
|
||||
_validationMessage = State(initialValue: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -174,6 +178,12 @@ struct JobEditorView: View {
|
||||
Toggle("Archive mode (-a)", isOn: $archive)
|
||||
Toggle("Compress during transfer (-z)", isOn: $compress)
|
||||
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
|
||||
if deleteExtraneous && (scheduleEnabled || watchEnabled) {
|
||||
Toggle("Allow unattended delete", isOn: $allowUnattendedDelete)
|
||||
Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Toggle("Preserve permissions", isOn: $preservePermissions)
|
||||
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
|
||||
Toggle("Show transfer progress", isOn: $showProgress)
|
||||
@@ -191,6 +201,9 @@ struct JobEditorView: View {
|
||||
Section("Advanced") {
|
||||
TextField("Additional rsync arguments", text: $additionalArguments)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -213,6 +226,14 @@ struct JobEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Invalid Sync Job", isPresented: Binding(
|
||||
get: { validationMessage != nil },
|
||||
set: { if !$0 { validationMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { validationMessage = nil }
|
||||
} message: {
|
||||
Text(validationMessage ?? "The job configuration is invalid.")
|
||||
}
|
||||
.frame(width: 690, height: 820)
|
||||
}
|
||||
|
||||
@@ -262,10 +283,22 @@ struct JobEditorView: View {
|
||||
job.watchEnabled = watchEnabled
|
||||
job.watchPath = watchPath
|
||||
job.watchDebounceSeconds = watchDebounceSeconds
|
||||
job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false
|
||||
job.updatedAt = Date()
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
|
||||
let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual
|
||||
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) {
|
||||
validationMessage = message
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "Could not save the job: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct JobListView: View {
|
||||
@Binding var showingNewJob: Bool
|
||||
@State private var searchText = ""
|
||||
@State private var filter: JobFilter = .all
|
||||
@State private var persistenceError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -50,6 +51,14 @@ struct JobListView: View {
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
}
|
||||
.alert("Database Error", isPresented: Binding(
|
||||
get: { persistenceError != nil },
|
||||
set: { if !$0 { persistenceError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { persistenceError = nil }
|
||||
} message: {
|
||||
Text(persistenceError ?? "Unable to save job changes.")
|
||||
}
|
||||
}
|
||||
|
||||
private var dashboardHeader: some View {
|
||||
@@ -198,6 +207,7 @@ struct JobListView: View {
|
||||
copy.watchEnabled = job.watchEnabled
|
||||
copy.watchPath = job.watchPath
|
||||
copy.watchDebounceSeconds = job.watchDebounceSeconds
|
||||
copy.allowUnattendedDelete = job.allowUnattendedDelete
|
||||
modelContext.insert(copy)
|
||||
saveAndRefresh()
|
||||
selection = copy
|
||||
@@ -210,8 +220,12 @@ struct JobListView: View {
|
||||
}
|
||||
|
||||
private func saveAndRefresh() {
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
do {
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
} catch {
|
||||
persistenceError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ struct RunHistoryView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
|
||||
@State private var selection: SyncRun?
|
||||
@State private var persistenceError: String?
|
||||
let onBack: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -24,7 +25,7 @@ struct RunHistoryView: View {
|
||||
Button("Clear", role: .destructive) {
|
||||
runs.forEach(modelContext.delete)
|
||||
selection = nil
|
||||
try? modelContext.save()
|
||||
saveHistoryChanges()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -79,7 +80,7 @@ struct RunHistoryView: View {
|
||||
Button("Delete Log", role: .destructive) {
|
||||
if selection?.id == run.id { selection = nil }
|
||||
modelContext.delete(run)
|
||||
try? modelContext.save()
|
||||
saveHistoryChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +104,19 @@ struct RunHistoryView: View {
|
||||
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.alert("Database Error", isPresented: Binding(
|
||||
get: { persistenceError != nil },
|
||||
set: { if !$0 { persistenceError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { persistenceError = nil }
|
||||
} message: {
|
||||
Text(persistenceError ?? "Unable to update run history.")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveHistoryChanges() {
|
||||
do { try modelContext.save() }
|
||||
catch { persistenceError = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func statusIcon(_ run: SyncRun) -> String {
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROJECT="$ROOT/RsyncManager.xcodeproj"
|
||||
SCHEME="RsyncManager"
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "release-preflight.sh must run on macOS with Xcode installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for tool in xcodebuild plutil codesign spctl; do
|
||||
command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 2; }
|
||||
done
|
||||
|
||||
"$SCRIPT_DIR/security-check.sh"
|
||||
plutil -lint "$ROOT/RsyncManager/Resources/PrivacyInfo.xcprivacy"
|
||||
|
||||
BUILD_DIR="$ROOT/build/preflight"
|
||||
rm -rf "$BUILD_DIR"
|
||||
|
||||
xcodebuild \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Release \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$BUILD_DIR" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
clean build
|
||||
|
||||
APP="$BUILD_DIR/Build/Products/Release/RsyncManager.app"
|
||||
test -d "$APP" || { echo "Release app was not produced at $APP" >&2; exit 1; }
|
||||
|
||||
echo "Unsigned Release build passed: $APP"
|
||||
echo "Next: Archive with Developer ID, notarise, staple, then run the signed-app checks in Docs/RELEASE.md."
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROJECT="$ROOT/RsyncManager.xcodeproj"
|
||||
|
||||
echo "== Rsync Manager project security checks =="
|
||||
grep -q 'ENABLE_HARDENED_RUNTIME = YES' "$PROJECT/project.pbxproj"
|
||||
grep -q 'ENABLE_APP_SANDBOX = NO' "$PROJECT/project.pbxproj"
|
||||
grep -q 'INFOPLIST_KEY_CFBundleIconName = AppIcon' "$PROJECT/project.pbxproj"
|
||||
test -f "$ROOT/RsyncManager/Resources/PrivacyInfo.xcprivacy"
|
||||
|
||||
if grep -R --line-number -E 'StrictHostKeyChecking[ =]+no|UserKnownHostsFile[ =]+/dev/null|sshpass' "$ROOT/RsyncManager" --include='*.swift'; then
|
||||
echo "Unsafe SSH configuration detected." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q 'BatchMode=yes' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift"
|
||||
grep -q 'allowUnattendedDelete' "$ROOT/RsyncManager/Models/SyncJob.swift"
|
||||
grep -q 'RsyncSecurityValidator' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift"
|
||||
grep -q 'isSafeRemotePath' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift"
|
||||
grep -q 'hasBalancedShellSyntax' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift"
|
||||
grep -q -- '--files-from' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift"
|
||||
grep -q -- '--log-file' "$ROOT/RsyncManager/Services/RsyncCommandBuilder.swift"
|
||||
|
||||
echo "Static policy checks passed."
|
||||
echo ""
|
||||
echo "After creating a signed Release app, also run:"
|
||||
echo " codesign --verify --deep --strict --verbose=2 /path/to/RsyncManager.app"
|
||||
echo " spctl --assess --type execute --verbose=4 /path/to/RsyncManager.app"
|
||||
echo " xcrun stapler validate /path/to/RsyncManager.app"
|
||||
Reference in New Issue
Block a user