iOS: device sync (Swift port of the Android engine) behind the sync_service channel
PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state, BGTaskScheduler background runs, and conflict notifications with Keep local / Use server. Reviewed by Codex against the Kotlin original; its data-loss findings are fixed and each has a regression test (fake-server end-to-end tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a manifest, unreadable state aborts the run, server paths can't leave the mirror, case/Unicode collisions are skipped, atomic downloads, a locally edited file deleted on the server is kept, '/' scope matching, serialised removal, tracked/cancellable background runs, per-account conflicts, and sign-out vs background-off credentials (cancel gains a 'forget' flag). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
1634d34341
commit
8805bafb97
@@ -831,9 +831,9 @@ The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
|
|||||||
`upload_service`, `download_service`, `sync_service`, plus their status
|
`upload_service`, `download_service`, `sync_service`, plus their status
|
||||||
`EventChannel`s) were written for Android in Kotlin. iOS implements
|
`EventChannel`s) were written for Android in Kotlin. iOS implements
|
||||||
`upload_service` (+ its status stream), `download_service` and
|
`upload_service` (+ its status stream), `download_service` and
|
||||||
`share_intent` so far (`ios/Runner/Native/` + `ios/ShareExtension/`, see
|
`share_intent` and `sync_service` so far (`ios/Runner/Native/` +
|
||||||
below); for `pick_intent` and `sync_service` there's no handler, so calls
|
`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so
|
||||||
throw `MissingPluginException`. `lib/services/native_channel.dart`
|
calls throw `MissingPluginException`. `lib/services/native_channel.dart`
|
||||||
makes that safe until each one is built in Swift:
|
makes that safe until each one is built in Swift:
|
||||||
|
|
||||||
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
|
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
|
||||||
@@ -987,5 +987,73 @@ mismatch); it's embedded by an "Embed Foundation Extensions" phase placed
|
|||||||
cycle). The simulator honours the App Group and keychain group without a
|
cycle). The simulator honours the App Group and keychain group without a
|
||||||
signing team; a real device needs a team that owns both ids.
|
signing team; a real device needs a team that owns both ids.
|
||||||
|
|
||||||
Not built on iOS yet: the picker (File Provider) and sync
|
### iOS device sync (`ios/Runner/Native/SyncEngine/`)
|
||||||
(`BGTaskScheduler`) - see the iOS handoff notes.
|
|
||||||
|
A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/
|
||||||
|
`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service`
|
||||||
|
channel (+ `/status` event stream) so the Dart side is unchanged apart from
|
||||||
|
`cancelAccount`'s `forget` flag (below). Same rules: only paths the user
|
||||||
|
synced are mirrored; brand-new local files are never uploaded (only edits to
|
||||||
|
already-synced files are); a file edited on both sides is a conflict (neither
|
||||||
|
copy is touched, "Keep local"/"Use server" resolves it); a local deletion is
|
||||||
|
repaired by re-downloading; the mirror lives at
|
||||||
|
`<Application Support>/sync/<accountId>/...` (what Dart's
|
||||||
|
`SyncService.baseDirectory()` reads for the Offline tab, excluded from
|
||||||
|
backup) with state in `<...>/sync-state/<accountId>/*.json`.
|
||||||
|
|
||||||
|
- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient`
|
||||||
|
(PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one
|
||||||
|
account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator`
|
||||||
|
(runs, `BGTaskScheduler`, notifications), and the `registerSync` channel
|
||||||
|
handlers in `NativeServices.swift`.
|
||||||
|
- **Safeguards (each has a regression test in `SyncEngineTests`, which drives
|
||||||
|
the real runner against an in-memory fake Nextcloud):** an unreachable
|
||||||
|
server, a non-207, or a PROPFIND answer that is truncated, not a
|
||||||
|
`multistatus`, empty, or has an href outside the account root / with dot
|
||||||
|
components throws `SyncRemoteUnavailable` and the path is skipped - never
|
||||||
|
diffed as empty or partial (which would delete local files); unreadable
|
||||||
|
recorded state makes the run do nothing (`SyncStateUnreadable`) instead of
|
||||||
|
treating every file as untracked and overwriting local edits; every
|
||||||
|
server-supplied path is resolved through `LocalFS.resolve` (no escaping the
|
||||||
|
mirror) and entries that would land on the same local file (case or Unicode
|
||||||
|
spellings) are skipped, not downloaded over each other; downloads go to a
|
||||||
|
temp file and replace atomically, never replacing a folder; empty-folder
|
||||||
|
pruning uses `rmdir` and only on a successful empty read; a root's etag
|
||||||
|
shortcut marker is only written when the path synced cleanly *and* the state
|
||||||
|
was saved.
|
||||||
|
- **Deliberate differences from Android:** a file deleted on the server but
|
||||||
|
edited here is **kept** (untracked) rather than deleted - the edit is the
|
||||||
|
only copy; after our own upload the file's etag is re-read so it isn't
|
||||||
|
downloaded back; state is flushed every 20 transfers; conflicts are kept
|
||||||
|
per account on the status bus; downloads write to a temp file first. No
|
||||||
|
ongoing progress notification (a run posts a summary, and conflict
|
||||||
|
notifications with Keep local / Use server actions).
|
||||||
|
- **Running:** every run - "Sync now", the in-app refresh, and background -
|
||||||
|
goes through `SyncCoordinator.startRun`: one tracked run per account
|
||||||
|
(`force` supersedes it, a quiet check leaves it alone), serialised by one
|
||||||
|
cancellable `AsyncMutex`, tagged with a per-account *generation* so a run
|
||||||
|
that was only queued never starts for an account that was cancelled or had a
|
||||||
|
path removed meanwhile. `removeLocalSync` cancels the run and takes the same
|
||||||
|
mutex, so a finishing run can't write stale state back over it.
|
||||||
|
- **Background:** `BGAppRefreshTask` + `BGProcessingTask`
|
||||||
|
(`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch +
|
||||||
|
processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers
|
||||||
|
registered before launch finishes). `intervalMinutes` (floored at 15, like
|
||||||
|
Android) is only the *earliest* start - iOS picks when - and Wi-Fi-only is
|
||||||
|
checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require
|
||||||
|
it. An expiring task cancels its runs (they save state as they go) and is
|
||||||
|
completed exactly once (`OnceGate`). **Not testable in the simulator**
|
||||||
|
(needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier`
|
||||||
|
debugger command).
|
||||||
|
- **Credentials:** `SyncConfigStore` keeps each account's server, auth header,
|
||||||
|
paths, interval and notify flag as one Keychain item
|
||||||
|
(`AfterFirstUnlock`, so a background run works with the phone locked).
|
||||||
|
Conflict notification actions look the account up there - nothing secret is
|
||||||
|
in a notification. `sync_service.cancel` takes `forget` (default true:
|
||||||
|
signed out/removed -> credentials dropped; false: only background sync was
|
||||||
|
turned off -> kept for manual runs and conflict actions). Dart sends
|
||||||
|
`forget: false` only for "paths configured but no background interval".
|
||||||
|
- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on
|
||||||
|
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
|
||||||
|
|
||||||
|
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; };
|
||||||
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
||||||
|
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; };
|
||||||
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
||||||
@@ -18,14 +20,19 @@
|
|||||||
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||||
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||||
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||||
|
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; };
|
||||||
|
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; };
|
||||||
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
|
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
|
||||||
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||||
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
|
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
|
||||||
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; };
|
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; };
|
||||||
|
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
|
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; };
|
||||||
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||||
|
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; };
|
||||||
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
|
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
|
||||||
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
@@ -33,6 +40,7 @@
|
|||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||||
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||||
|
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; };
|
||||||
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||||
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||||
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
||||||
@@ -81,12 +89,16 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = "<group>"; };
|
||||||
0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = "<group>"; };
|
||||||
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
||||||
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
||||||
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
|
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = "<group>"; };
|
||||||
|
308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = "<group>"; };
|
||||||
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = "<group>"; };
|
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = "<group>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -94,6 +106,7 @@
|
|||||||
405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = "<group>"; };
|
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = "<group>"; };
|
||||||
|
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = "<group>"; };
|
||||||
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = "<group>"; };
|
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = "<group>"; };
|
||||||
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -114,7 +127,10 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = "<group>"; };
|
||||||
|
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = "<group>"; };
|
||||||
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = "<group>"; };
|
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = "<group>"; };
|
||||||
|
C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -149,6 +165,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */,
|
||||||
);
|
);
|
||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -171,6 +188,7 @@
|
|||||||
children = (
|
children = (
|
||||||
8064CC1652B8F0A44E371638 /* TransferManager.swift */,
|
8064CC1652B8F0A44E371638 /* TransferManager.swift */,
|
||||||
901C0CDB4FB819302E2A7563 /* NativeServices.swift */,
|
901C0CDB4FB819302E2A7563 /* NativeServices.swift */,
|
||||||
|
DDC89BA8A42212F55851CBA1 /* SyncEngine */,
|
||||||
);
|
);
|
||||||
name = Native;
|
name = Native;
|
||||||
path = Native;
|
path = Native;
|
||||||
@@ -252,6 +270,21 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
DDC89BA8A42212F55851CBA1 /* SyncEngine */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */,
|
||||||
|
A39E02713460B6C7875F3278 /* SyncStore.swift */,
|
||||||
|
308A73EC19D196226AB395DC /* DavSyncClient.swift */,
|
||||||
|
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */,
|
||||||
|
C028B2E4E2874BB74498EABD /* SyncRunner.swift */,
|
||||||
|
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */,
|
||||||
|
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */,
|
||||||
|
);
|
||||||
|
name = SyncEngine;
|
||||||
|
path = SyncEngine;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -425,6 +458,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -461,6 +495,13 @@
|
|||||||
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
|
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
|
||||||
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
|
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
|
||||||
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */,
|
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */,
|
||||||
|
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */,
|
||||||
|
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */,
|
||||||
|
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */,
|
||||||
|
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */,
|
||||||
|
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */,
|
||||||
|
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */,
|
||||||
|
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,11 +9,25 @@ import UserNotifications
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
TransferManager.shared.reconnect()
|
TransferManager.shared.reconnect()
|
||||||
|
// BGTask handlers have to be registered before launch finishes.
|
||||||
|
SyncCoordinator.shared.registerBackgroundTasks()
|
||||||
|
SyncCoordinator.shared.registerNotificationCategories()
|
||||||
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
SyncCoordinator.shared.scheduleBackgroundWork()
|
||||||
return launched
|
return launched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Taps on a sync-conflict notification's "Keep local" / "Use server".
|
||||||
|
override func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return }
|
||||||
|
super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
/// A transfer summary ("Downloaded 1 file") usually finishes while the app
|
/// A transfer summary ("Downloaded 1 file") usually finishes while the app
|
||||||
/// is open; without this iOS delivers it silently to Notification Center
|
/// is open; without this iOS delivers it silently to Notification Center
|
||||||
/// instead of showing the banner.
|
/// instead of showing the banner.
|
||||||
|
|||||||
@@ -59,6 +59,16 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NooKeychainAccessGroup</key>
|
<key>NooKeychainAccessGroup</key>
|
||||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>dev.ayushya.noo.sync.refresh</string>
|
||||||
|
<string>dev.ayushya.noo.sync.processing</string>
|
||||||
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
|||||||
@@ -76,6 +76,115 @@ enum NativeServices {
|
|||||||
TransferManager.shared.reconnect()
|
TransferManager.shared.reconnect()
|
||||||
registerShare(messenger: messenger)
|
registerShare(messenger: messenger)
|
||||||
registerShareAccount(messenger: messenger)
|
registerShareAccount(messenger: messenger)
|
||||||
|
registerSync(messenger: messenger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device sync
|
||||||
|
|
||||||
|
/// Sends the current sync status the moment Dart starts listening, then
|
||||||
|
/// every change - same as Android's `SyncStatusBus.subscribe`.
|
||||||
|
private final class SyncStatusStream: NSObject, FlutterStreamHandler {
|
||||||
|
var sink: FlutterEventSink?
|
||||||
|
|
||||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||||
|
-> FlutterError?
|
||||||
|
{
|
||||||
|
sink = events
|
||||||
|
let coordinator = SyncCoordinator.shared
|
||||||
|
events(coordinator.statusMap(coordinator.bus.snapshot()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
|
sink = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let syncStatusStream = SyncStatusStream()
|
||||||
|
/// Serial, so status events reach Dart in the order they happened even
|
||||||
|
/// though building each one reads the state from disk.
|
||||||
|
private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility)
|
||||||
|
|
||||||
|
/// `sync_service`: the same methods Android's `MainActivity.kt` serves,
|
||||||
|
/// backed by `SyncCoordinator`/`SyncRunner`.
|
||||||
|
private static func registerSync(messenger: FlutterBinaryMessenger) {
|
||||||
|
let coordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
let args = call.arguments as? [String: Any] ?? [:]
|
||||||
|
switch call.method {
|
||||||
|
case "reschedule":
|
||||||
|
handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) }
|
||||||
|
case "cancel":
|
||||||
|
// `forget` (default true) = signed out / removed; false = only the
|
||||||
|
// background job is off - credentials stay for manual runs and
|
||||||
|
// conflict actions.
|
||||||
|
handle(call, result) { _ in
|
||||||
|
coordinator.cancel(
|
||||||
|
accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true)
|
||||||
|
}
|
||||||
|
case "syncNow":
|
||||||
|
handle(call, result) { _ in
|
||||||
|
let force = args["force"] as? Bool ?? true
|
||||||
|
coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool)
|
||||||
|
}
|
||||||
|
case "getSyncStatus":
|
||||||
|
let accountId = args["accountId"] as? String
|
||||||
|
syncStatusQueue.async {
|
||||||
|
let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId)
|
||||||
|
DispatchQueue.main.async { result(map) }
|
||||||
|
}
|
||||||
|
case "resolveConflict":
|
||||||
|
handle(call, result) { _ in
|
||||||
|
let conflict = SyncConflict(
|
||||||
|
accountId: try string(args, "accountId"), fileId: try string(args, "fileId"),
|
||||||
|
remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"),
|
||||||
|
name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent)
|
||||||
|
let creds = SyncCredentials(
|
||||||
|
serverUrl: try string(args, "serverUrl"), username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"))
|
||||||
|
let resolution = try string(args, "resolution")
|
||||||
|
// Fire and forget, like Android: the status stream reports the result.
|
||||||
|
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
|
||||||
|
}
|
||||||
|
case "removeLocalSync":
|
||||||
|
guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil))
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await coordinator.removeLocalSync(accountId: accountId, path: path)
|
||||||
|
await MainActor.run { result(nil) }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger)
|
||||||
|
.setStreamHandler(syncStatusStream)
|
||||||
|
coordinator.bus.onChange = { status in
|
||||||
|
syncStatusQueue.async {
|
||||||
|
let map = coordinator.statusMap(status)
|
||||||
|
DispatchQueue.main.async { syncStatusStream.sink?(map) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account credentials + settings every sync call carries, as a config.
|
||||||
|
private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig {
|
||||||
|
var folders: [String] = []
|
||||||
|
if let json = args["folders"] as? String, let data = json.data(using: .utf8),
|
||||||
|
let list = try? JSONSerialization.jsonObject(with: data) as? [String]
|
||||||
|
{
|
||||||
|
folders = list
|
||||||
|
}
|
||||||
|
return SyncAccountConfig(
|
||||||
|
accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"), authHeader: try string(args, "authHeader"),
|
||||||
|
folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true,
|
||||||
|
intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Account for the Share Extension
|
// MARK: - Account for the Share Extension
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Credentials for one account's WebDAV root.
|
||||||
|
struct SyncCredentials {
|
||||||
|
let serverUrl: String
|
||||||
|
let username: String
|
||||||
|
let authHeader: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item),
|
||||||
|
/// a recursive walk, and GET/PUT of a single file. Foreground
|
||||||
|
/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a
|
||||||
|
/// background session, so a run can walk, diff and decide in one go.
|
||||||
|
///
|
||||||
|
/// Authenticates by each request's own `Authorization` header only (no cookies,
|
||||||
|
/// no credential cache): two accounts can share a server, and one account's
|
||||||
|
/// session must never answer for another.
|
||||||
|
final class DavSyncClient {
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init(session: URLSession = DavSyncClient.makeSession()) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeSession() -> URLSession {
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
config.timeoutIntervalForResource = 15 * 60
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PROPFIND
|
||||||
|
|
||||||
|
private static let propfindBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getetag/>
|
||||||
|
<oc:fileid/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the
|
||||||
|
/// folder is genuinely gone) is an empty list; anything else that goes wrong
|
||||||
|
/// throws `SyncRemoteUnavailable`.
|
||||||
|
func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] {
|
||||||
|
try await propfind(creds, path: path, depth: "1", skipSelf: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-0 PROPFIND of [path] itself - nil on a clean 404.
|
||||||
|
func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? {
|
||||||
|
try await propfind(creds, path: path, depth: "0", skipSelf: false).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func propfind(
|
||||||
|
_ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool
|
||||||
|
) async throws -> [SyncRemoteEntry] {
|
||||||
|
var clean = path.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !clean.hasPrefix("/") { clean = "/" + clean }
|
||||||
|
guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean)
|
||||||
|
else { throw SyncRemoteUnavailable(message: "Invalid server address.") }
|
||||||
|
// Collections are addressed with a trailing slash.
|
||||||
|
if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PROPFIND"
|
||||||
|
request.setValue(depth, forHTTPHeaderField: "Depth")
|
||||||
|
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = Self.propfindBody.data(using: .utf8)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let status: Int
|
||||||
|
do {
|
||||||
|
let (body, response) = try await session.data(for: request)
|
||||||
|
data = body
|
||||||
|
status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
} catch {
|
||||||
|
try Self.rethrowIfCancelled(error)
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
if status == 404 { return [] } // genuinely gone
|
||||||
|
guard status == 207 else {
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)")
|
||||||
|
}
|
||||||
|
return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat
|
||||||
|
/// manifest. Throws if *any* level fails - a partial manifest would look
|
||||||
|
/// like the missing part had been deleted on the server.
|
||||||
|
func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] {
|
||||||
|
var result: [SyncRemoteEntry] = []
|
||||||
|
var queue = [root]
|
||||||
|
var index = 0
|
||||||
|
while index < queue.count {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let children = try await propfindChildren(creds, path: queue[index])
|
||||||
|
index += 1
|
||||||
|
for child in children {
|
||||||
|
result.append(child)
|
||||||
|
if child.isFolder { queue.append(child.path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GET / PUT
|
||||||
|
|
||||||
|
/// Downloads [remotePath] to [destination]. Written to a temp file first and
|
||||||
|
/// moved into place, so an interrupted download never leaves a truncated
|
||||||
|
/// file where a good one used to be. False on any failure.
|
||||||
|
func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
||||||
|
else { return false }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
do {
|
||||||
|
let (temp, response) = try await session.download(for: request)
|
||||||
|
guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else {
|
||||||
|
try? FileManager.default.removeItem(at: temp)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
|
||||||
|
// Never replace a folder with a file, and replace a file atomically:
|
||||||
|
// the old copy stays until the new one is fully in place.
|
||||||
|
guard !isDirectory.boolValue else {
|
||||||
|
try? fm.removeItem(at: temp)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = try fm.replaceItemAt(destination, withItemAt: temp)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: temp, to: destination)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
try Self.rethrowIfCancelled(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUTs [source] to [remotePath]. False on any failure.
|
||||||
|
func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
||||||
|
else { return false }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
do {
|
||||||
|
let (_, response) = try await session.upload(for: request, fromFile: source)
|
||||||
|
guard let status = (response as? HTTPURLResponse)?.statusCode else { return false }
|
||||||
|
return (200..<300).contains(status)
|
||||||
|
} catch {
|
||||||
|
try Self.rethrowIfCancelled(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cancelled run must stop, not be mistaken for a flaky network.
|
||||||
|
private static func rethrowIfCancelled(_ error: Error) throws {
|
||||||
|
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's
|
||||||
|
/// unit-tested.
|
||||||
|
enum DavSyncParser {
|
||||||
|
private struct Raw {
|
||||||
|
var href = ""
|
||||||
|
var etag = ""
|
||||||
|
var modified = ""
|
||||||
|
var length = ""
|
||||||
|
var fileId = ""
|
||||||
|
var isCollection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Delegate: NSObject, XMLParserDelegate {
|
||||||
|
var responses: [Raw] = []
|
||||||
|
var sawMultistatus = false
|
||||||
|
private var current: Raw?
|
||||||
|
private var text = ""
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||||
|
) {
|
||||||
|
text = ""
|
||||||
|
if elementName == "multistatus" { sawMultistatus = true }
|
||||||
|
if elementName == "response" { current = Raw() }
|
||||||
|
if elementName == "collection" { current?.isCollection = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||||
|
text += string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?
|
||||||
|
) {
|
||||||
|
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
switch elementName {
|
||||||
|
case "href": current?.href = value
|
||||||
|
case "getetag": current?.etag = value
|
||||||
|
case "getlastmodified": current?.modified = value
|
||||||
|
case "getcontentlength": current?.length = value
|
||||||
|
case "fileid": current?.fileId = value
|
||||||
|
case "response":
|
||||||
|
if let current { responses.append(current) }
|
||||||
|
current = nil
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let httpDate: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for
|
||||||
|
/// anything that isn't a complete, plausible answer - malformed or
|
||||||
|
/// truncated XML, no `multistatus` envelope, no responses at all (a 207
|
||||||
|
/// always has at least the requested resource), or an href that isn't
|
||||||
|
/// under this account's files root or tries to climb out of it. A partial
|
||||||
|
/// manifest would look like the missing part was deleted on the server
|
||||||
|
/// (and an empty one like the whole path was), so the caller must treat
|
||||||
|
/// those as "couldn't reach the server", never as data.
|
||||||
|
static func entries(
|
||||||
|
from xml: Data, username: String, requestedPath: String, skipSelf: Bool
|
||||||
|
) throws -> [SyncRemoteEntry] {
|
||||||
|
let delegate = Delegate()
|
||||||
|
let parser = XMLParser(data: xml)
|
||||||
|
parser.shouldProcessNamespaces = true
|
||||||
|
parser.delegate = delegate
|
||||||
|
guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else {
|
||||||
|
throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = "/remote.php/dav/files/\(username)"
|
||||||
|
let selfPath = trimTrailingSlashes(requestedPath)
|
||||||
|
|
||||||
|
var entries: [SyncRemoteEntry] = []
|
||||||
|
for raw in delegate.responses {
|
||||||
|
let decoded = raw.href.removingPercentEncoding ?? raw.href
|
||||||
|
guard let range = decoded.range(of: marker) else {
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root")
|
||||||
|
}
|
||||||
|
let hrefPath = String(decoded[range.upperBound...])
|
||||||
|
// A well-formed path continues the marker at a component boundary and
|
||||||
|
// never contains a dot component.
|
||||||
|
guard hrefPath.isEmpty || hrefPath.hasPrefix("/"),
|
||||||
|
!hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." })
|
||||||
|
else {
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path")
|
||||||
|
}
|
||||||
|
let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath)
|
||||||
|
if skipSelf && normalized == selfPath { continue } // the folder itself, not a child
|
||||||
|
entries.append(
|
||||||
|
SyncRemoteEntry(
|
||||||
|
path: normalized.isEmpty ? "/" : normalized,
|
||||||
|
fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId,
|
||||||
|
etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
|
||||||
|
lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0,
|
||||||
|
size: Int64(raw.length) ?? 0,
|
||||||
|
isFolder: raw.isCollection))
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimTrailingSlashes(_ path: String) -> String {
|
||||||
|
var p = path
|
||||||
|
while p.hasSuffix("/") { p.removeLast() }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Every account's sync settings and credentials, kept in the Keychain (the
|
||||||
|
/// auth header is a secret) as one JSON item. A background run - which may
|
||||||
|
/// start while the app isn't running - reads these to know what to sync and
|
||||||
|
/// as whom, and a conflict notification's action looks the account up here
|
||||||
|
/// instead of carrying credentials in the notification itself.
|
||||||
|
///
|
||||||
|
/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background
|
||||||
|
/// task can fire with the phone locked.
|
||||||
|
///
|
||||||
|
/// All accounts live in one item, so a write must never be built on a
|
||||||
|
/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and
|
||||||
|
/// say so) rather than save a map holding only one account, and an update
|
||||||
|
/// replaces the item in place instead of deleting it first.
|
||||||
|
final class SyncConfigStore {
|
||||||
|
private struct KeychainError: Error { let status: OSStatus }
|
||||||
|
|
||||||
|
private let service: String
|
||||||
|
private let account = "configs"
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
init(service: String = "dev.ayushya.noo.sync-configs") {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty if there are none - or if the item can't be read, in which case
|
||||||
|
/// nothing runs in the background until it can.
|
||||||
|
func all() -> [SyncAccountConfig] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId }
|
||||||
|
}
|
||||||
|
|
||||||
|
func config(for accountId: String) -> SyncAccountConfig? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return (try? load())?[accountId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False if it couldn't be stored (the previous contents are untouched).
|
||||||
|
@discardableResult
|
||||||
|
func upsert(_ config: SyncAccountConfig) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
do {
|
||||||
|
var configs = try load()
|
||||||
|
configs[config.accountId] = config
|
||||||
|
try save(configs)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func remove(accountId: String) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
do {
|
||||||
|
var configs = try load()
|
||||||
|
guard configs.removeValue(forKey: accountId) != nil else { return true }
|
||||||
|
try save(configs)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain
|
||||||
|
|
||||||
|
private func baseQuery() -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[:]` only when nothing is stored yet; any other failure throws.
|
||||||
|
private func load() throws -> [String: SyncAccountConfig] {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return [:] }
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) }
|
||||||
|
return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(_ configs: [String: SyncAccountConfig]) throws {
|
||||||
|
guard !configs.isEmpty else {
|
||||||
|
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let data = try JSONEncoder().encode(configs) // before touching the item
|
||||||
|
let status = SecItemUpdate(
|
||||||
|
baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||||
|
if status == errSecItemNotFound {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecValueData as String] = data
|
||||||
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
let added = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
if added != errSecSuccess { throw KeychainError(status: added) }
|
||||||
|
} else if status != errSecSuccess {
|
||||||
|
throw KeychainError(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Network
|
||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Whether the network currently allows a sync run: any connection normally,
|
||||||
|
/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) -
|
||||||
|
/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint,
|
||||||
|
/// which a `BGTask` doesn't offer.
|
||||||
|
enum NetworkGate {
|
||||||
|
static func allows(wifiOnly: Bool) async -> Bool {
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate")
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
var answered = false // only touched on `queue`
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
guard !answered else { return }
|
||||||
|
answered = true
|
||||||
|
monitor.cancel()
|
||||||
|
let connected = path.status == .satisfied
|
||||||
|
continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive))
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs an action at most once - for completing a `BGTask`, which must be
|
||||||
|
/// told it's done exactly one time whether the work finished or the system's
|
||||||
|
/// time ran out first.
|
||||||
|
final class OnceGate {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var done = false
|
||||||
|
private let action: (Bool) -> Void
|
||||||
|
|
||||||
|
init(_ action: @escaping (Bool) -> Void) {
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ success: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
if done {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done = true
|
||||||
|
lock.unlock()
|
||||||
|
action(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side
|
||||||
|
/// asks for, the periodic background runs iOS grants through
|
||||||
|
/// `BGTaskScheduler`, conflict resolution, and the notifications around them.
|
||||||
|
/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s
|
||||||
|
/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`.
|
||||||
|
///
|
||||||
|
/// Every run - foreground or background - goes through `startRun`, so one
|
||||||
|
/// per-account record knows what's running, "Sync now" can supersede it and a
|
||||||
|
/// quiet check can leave it alone, and signing out or removing a path can
|
||||||
|
/// cancel it. Runs are also serialised against each other (and against
|
||||||
|
/// removal) by one mutex, and carry the account's *generation*: cancelling or
|
||||||
|
/// removing bumps it, so a run that was only queued behind another never
|
||||||
|
/// starts for an account that's gone in the meantime.
|
||||||
|
///
|
||||||
|
/// iOS decides *when* background work runs (typically while charging, on
|
||||||
|
/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the
|
||||||
|
/// earliest it may start, not a schedule, and a run is cut off when the
|
||||||
|
/// system's time budget ends (the runner saves its state as it goes).
|
||||||
|
final class SyncCoordinator {
|
||||||
|
static let shared = SyncCoordinator()
|
||||||
|
|
||||||
|
static let refreshTaskId = "dev.ayushya.noo.sync.refresh"
|
||||||
|
static let processingTaskId = "dev.ayushya.noo.sync.processing"
|
||||||
|
|
||||||
|
static let conflictCategory = "dev.ayushya.noo.sync.conflict"
|
||||||
|
static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal"
|
||||||
|
static let useServerAction = "dev.ayushya.noo.sync.useServer"
|
||||||
|
|
||||||
|
let store: SyncStore
|
||||||
|
let bus: SyncStatusBus
|
||||||
|
let configs: SyncConfigStore
|
||||||
|
private let runner: SyncRunner
|
||||||
|
|
||||||
|
/// One run at a time, app-wide (see `AsyncMutex`).
|
||||||
|
private let mutex = AsyncMutex()
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var running: [String: (token: UUID, task: Task<Void, Never>)] = [:]
|
||||||
|
private var generations: [String: Int] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(),
|
||||||
|
configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient()
|
||||||
|
) {
|
||||||
|
self.store = store
|
||||||
|
self.bus = bus
|
||||||
|
self.configs = configs
|
||||||
|
self.runner = SyncRunner(client: client, store: store, bus: bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings from Dart
|
||||||
|
|
||||||
|
/// Brings the background job in line with [config] (called whenever the
|
||||||
|
/// synced paths, account or network setting changes). A config with no
|
||||||
|
/// folders means this account has nothing to sync.
|
||||||
|
func reschedule(_ config: SyncAccountConfig) {
|
||||||
|
if config.folders.isEmpty {
|
||||||
|
cancel(accountId: config.accountId, forget: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configs.upsert(config)
|
||||||
|
scheduleBackgroundWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [forget] true: the account was removed or signed out - stop its run and
|
||||||
|
/// drop its settings and credentials. [forget] false: only background sync
|
||||||
|
/// was turned off (manual refresh is still possible) - the credentials stay,
|
||||||
|
/// since a conflict notification's actions and the next "Sync now" need
|
||||||
|
/// them, and a run in progress is left to finish.
|
||||||
|
func cancel(accountId: String, forget: Bool = true) {
|
||||||
|
if forget {
|
||||||
|
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||||
|
configs.remove(accountId: accountId)
|
||||||
|
} else if var config = configs.config(for: accountId) {
|
||||||
|
config.intervalMinutes = nil
|
||||||
|
configs.upsert(config)
|
||||||
|
}
|
||||||
|
scheduleBackgroundWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-off run. [force] is an explicit "Sync now" / newly added path:
|
||||||
|
/// a full walk, a summary notification, and it supersedes any run already
|
||||||
|
/// going for this account; without it (pull-to-refresh, foreground timer,
|
||||||
|
/// app resume) it's a quiet check that never interrupts a run in progress.
|
||||||
|
/// [wifiOnly] non-nil means "respect the Wi-Fi-only setting".
|
||||||
|
func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) {
|
||||||
|
if config.folders.isEmpty { return }
|
||||||
|
// Keep the background settings this call doesn't carry.
|
||||||
|
var stored = config
|
||||||
|
if let existing = configs.config(for: config.accountId) {
|
||||||
|
stored.intervalMinutes = existing.intervalMinutes
|
||||||
|
stored.wifiOnly = existing.wifiOnly
|
||||||
|
}
|
||||||
|
configs.upsert(stored)
|
||||||
|
startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that
|
||||||
|
/// are only waiting their turn from ever starting.
|
||||||
|
private func cancelRun(accountId: String, invalidateQueued: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
if invalidateQueued { generations[accountId, default: 0] += 1 }
|
||||||
|
running[accountId]?.task.cancel()
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generation(of accountId: String) -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generations[accountId] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a tracked run, or - for a non-forced one when this account is
|
||||||
|
/// already running - returns nil and leaves the current run alone.
|
||||||
|
@discardableResult
|
||||||
|
private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task<Void, Never>? {
|
||||||
|
lock.lock()
|
||||||
|
if let existing = running[config.accountId] {
|
||||||
|
if force {
|
||||||
|
existing.task.cancel()
|
||||||
|
} else {
|
||||||
|
lock.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = UUID()
|
||||||
|
let generation = generations[config.accountId] ?? 0
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation)
|
||||||
|
self?.finished(accountId: config.accountId, token: token)
|
||||||
|
}
|
||||||
|
running[config.accountId] = (token, task)
|
||||||
|
lock.unlock()
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finished(accountId: String, token: UUID) {
|
||||||
|
lock.lock()
|
||||||
|
if running[accountId]?.token == token { running[accountId] = nil }
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning sync off for [path]: stop this account's run first and do the
|
||||||
|
/// removal under the same mutex as runs - a run still holding the old
|
||||||
|
/// state would otherwise write it back over the removal (and a transfer
|
||||||
|
/// finishing after it would leave a file the state doesn't know about).
|
||||||
|
func removeLocalSync(accountId: String, path: String) async {
|
||||||
|
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||||
|
_ = try? await mutex.withLock {
|
||||||
|
store.removeLocalSync(accountId: accountId, path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride]
|
||||||
|
/// is the account Dart is asking about: the bus only learns an account once
|
||||||
|
/// a run has happened in this process, so on a fresh start the durable
|
||||||
|
/// per-file synced state would otherwise be reported as empty.
|
||||||
|
func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] {
|
||||||
|
let accountId = accountIdOverride ?? status.accountId
|
||||||
|
// Live syncing state belongs to whichever account last synced.
|
||||||
|
let busMatches = status.accountId == nil || status.accountId == accountId
|
||||||
|
let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? []
|
||||||
|
let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? []
|
||||||
|
return [
|
||||||
|
"accountId": accountId as Any,
|
||||||
|
"syncing": busMatches && status.syncing,
|
||||||
|
"syncingFileIds": busMatches ? Array(status.syncingFileIds) : [],
|
||||||
|
"syncedFileIds": synced,
|
||||||
|
"missingRoots": missing,
|
||||||
|
// Conflicts are per account (a file id is only unique within one).
|
||||||
|
"conflicts": status.conflicts.filter { $0.accountId == accountId }.map {
|
||||||
|
[
|
||||||
|
"accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath,
|
||||||
|
"relPath": $0.relPath, "name": $0.name,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Running
|
||||||
|
|
||||||
|
/// One account's pass, serialised against every other run.
|
||||||
|
private func execute(
|
||||||
|
_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int
|
||||||
|
) async {
|
||||||
|
// A few extra seconds if the app is backgrounded mid-run; if even those
|
||||||
|
// run out, stop this account's run so it saves and ends cleanly.
|
||||||
|
let box = BackgroundTaskBox()
|
||||||
|
box.id = await MainActor.run {
|
||||||
|
UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in
|
||||||
|
self?.cancelRun(accountId: config.accountId, invalidateQueued: false)
|
||||||
|
UIApplication.shared.endBackgroundTask(box.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await mutex.withLock {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
// Gone (signed out / removed) while this was queued?
|
||||||
|
guard self.generation(of: config.accountId) == generation,
|
||||||
|
self.configs.config(for: config.accountId) != nil
|
||||||
|
else { return }
|
||||||
|
if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return }
|
||||||
|
let summary = await runner.run(config, force: force)
|
||||||
|
if summary.changedAnything && (force || config.notify) {
|
||||||
|
SyncNotifications.summary(summary, username: config.username, accountId: config.accountId)
|
||||||
|
}
|
||||||
|
SyncNotifications.conflicts(summary.conflicts, username: config.username)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cancelled while waiting for its turn - nothing ran.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a conflict from a notification action, using the stored
|
||||||
|
/// credentials for its account.
|
||||||
|
func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool {
|
||||||
|
(try? await mutex.withLock {
|
||||||
|
await runner.resolveConflict(conflict, resolution: resolution, creds: creds)
|
||||||
|
}) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background tasks
|
||||||
|
|
||||||
|
/// Registers the two `BGTask` handlers. Must run before the app finishes
|
||||||
|
/// launching (see `AppDelegate`).
|
||||||
|
func registerBackgroundTasks() {
|
||||||
|
for id in [Self.refreshTaskId, Self.processingTaskId] {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in
|
||||||
|
self?.handleBackground(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks iOS for the next background run: a short app-refresh slot and a
|
||||||
|
/// longer processing slot (the system picks when, e.g. overnight on power).
|
||||||
|
/// Nothing is requested when no account has background sync turned on.
|
||||||
|
func scheduleBackgroundWork() {
|
||||||
|
let scheduler = BGTaskScheduler.shared
|
||||||
|
let due = backgroundConfigs()
|
||||||
|
guard let minutes = due.compactMap(\.intervalMinutes).min() else {
|
||||||
|
scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId)
|
||||||
|
scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same floor as Android's periodic work.
|
||||||
|
let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60))
|
||||||
|
|
||||||
|
let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId)
|
||||||
|
refresh.earliestBeginDate = earliest
|
||||||
|
let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId)
|
||||||
|
processing.earliestBeginDate = earliest
|
||||||
|
processing.requiresNetworkConnectivity = true
|
||||||
|
processing.requiresExternalPower = false
|
||||||
|
for request in [refresh, processing] as [BGTaskRequest] {
|
||||||
|
do { try scheduler.submit(request) } catch {
|
||||||
|
NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func backgroundConfigs() -> [SyncAccountConfig] {
|
||||||
|
configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A background slot: runs every account that has background sync on, each
|
||||||
|
/// through the normal tracked path (so it can be cancelled by sign-out, and
|
||||||
|
/// a run already going is left alone). The task is reported complete
|
||||||
|
/// exactly once - when the work ends or when the system's time does.
|
||||||
|
private func handleBackground(_ task: BGTask) {
|
||||||
|
scheduleBackgroundWork() // line up the next one before this one runs
|
||||||
|
let gate = OnceGate { task.setTaskCompleted(success: $0) }
|
||||||
|
let accountIds = backgroundConfigs().map(\.accountId)
|
||||||
|
let work = Task { [weak self] in
|
||||||
|
guard let self else { return gate.finish(false) }
|
||||||
|
for config in self.backgroundConfigs() {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value
|
||||||
|
}
|
||||||
|
gate.finish(!Task.isCancelled)
|
||||||
|
}
|
||||||
|
// The system's time is up: stop the work and the runs it started (each
|
||||||
|
// saves what it has) and report.
|
||||||
|
task.expirationHandler = { [weak self] in
|
||||||
|
work.cancel()
|
||||||
|
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
|
||||||
|
gate.finish(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
/// Registers the conflict notification's actions. Call once at launch.
|
||||||
|
func registerNotificationCategories() {
|
||||||
|
let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: [])
|
||||||
|
let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: [])
|
||||||
|
let category = UNNotificationCategory(
|
||||||
|
identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: [])
|
||||||
|
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a tap on a conflict notification's action. Returns whether the
|
||||||
|
/// response was ours (so `AppDelegate` knows not to pass it on).
|
||||||
|
@MainActor
|
||||||
|
func handleNotificationResponse(
|
||||||
|
_ response: UNNotificationResponse, completion: @escaping () -> Void
|
||||||
|
) -> Bool {
|
||||||
|
let content = response.notification.request.content
|
||||||
|
guard content.categoryIdentifier == Self.conflictCategory else { return false }
|
||||||
|
let resolution: String
|
||||||
|
switch response.actionIdentifier {
|
||||||
|
case Self.keepLocalAction: resolution = "local"
|
||||||
|
case Self.useServerAction: resolution = "server"
|
||||||
|
default:
|
||||||
|
completion() // plain tap: just opens the app
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let info = content.userInfo
|
||||||
|
guard let accountId = info["accountId"] as? String,
|
||||||
|
let fileId = info["fileId"] as? String,
|
||||||
|
let remotePath = info["remotePath"] as? String,
|
||||||
|
let relPath = info["relPath"] as? String,
|
||||||
|
let config = configs.config(for: accountId)
|
||||||
|
else {
|
||||||
|
completion()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let conflict = SyncConflict(
|
||||||
|
accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath,
|
||||||
|
name: (relPath as NSString).lastPathComponent)
|
||||||
|
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||||
|
let box = BackgroundTaskBox()
|
||||||
|
box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") {
|
||||||
|
completion()
|
||||||
|
UIApplication.shared.endBackgroundTask(box.id)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds)
|
||||||
|
if ok {
|
||||||
|
UNUserNotificationCenter.current().removeDeliveredNotifications(
|
||||||
|
withIdentifiers: [response.notification.request.identifier])
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created
|
||||||
|
/// before the identifier exists) can end the task it belongs to.
|
||||||
|
final class BackgroundTaskBox {
|
||||||
|
var id: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SyncAccountConfig {
|
||||||
|
func withNotify(_ notify: Bool) -> SyncAccountConfig {
|
||||||
|
var copy = self
|
||||||
|
copy.notify = notify
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local notifications sync posts: a summary of what changed, and one per
|
||||||
|
/// conflict (which always posts - it needs a decision) with Keep local / Use
|
||||||
|
/// server actions.
|
||||||
|
enum SyncNotifications {
|
||||||
|
static func summary(_ summary: SyncRunSummary, username: String, accountId: String) {
|
||||||
|
var parts: [String] = []
|
||||||
|
if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") }
|
||||||
|
if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") }
|
||||||
|
if summary.deleted > 0 { parts.append("\(summary.deleted) removed") }
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo sync · \(username)"
|
||||||
|
content.body = parts.joined(separator: ", ")
|
||||||
|
// One per account, so a newer summary replaces an older one in place.
|
||||||
|
post(content, id: "sync.summary.\(accountId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func conflicts(_ conflicts: [SyncConflict], username: String) {
|
||||||
|
for conflict in conflicts {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Sync conflict: \(conflict.name)"
|
||||||
|
content.body = "\(username) - changed both on this device and on the server."
|
||||||
|
content.categoryIdentifier = SyncCoordinator.conflictCategory
|
||||||
|
content.userInfo = [
|
||||||
|
"accountId": conflict.accountId, "fileId": conflict.fileId,
|
||||||
|
"remotePath": conflict.remotePath, "relPath": conflict.relPath,
|
||||||
|
]
|
||||||
|
post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ content: UNMutableNotificationContent, id: String) {
|
||||||
|
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A local file's size and modification time (ms), the pair sync compares
|
||||||
|
/// against what it recorded.
|
||||||
|
struct LocalStat: Equatable {
|
||||||
|
let size: Int64
|
||||||
|
let mtimeMs: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocalFS {
|
||||||
|
/// nil if [url] doesn't exist.
|
||||||
|
static func stat(_ url: URL) -> LocalStat? {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
|
||||||
|
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0
|
||||||
|
return LocalStat(size: size, mtimeMs: mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relative(_ path: String) -> String {
|
||||||
|
path.hasPrefix("/") ? String(path.dropFirst()) : path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func trimTrailingSlashes(_ path: String) -> String {
|
||||||
|
var p = path
|
||||||
|
while p.hasSuffix("/") { p.removeLast() }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [rel] under [root], or nil if it isn't safely inside it: empty, with a
|
||||||
|
/// `.`/`..` component, or resolving outside [root]. Every path that came
|
||||||
|
/// from the server goes through this before anything is read, written or
|
||||||
|
/// deleted, so a hostile or buggy server can't point sync at other files.
|
||||||
|
static func resolve(_ rel: String, in root: URL) -> URL? {
|
||||||
|
let parts = rel.split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil }
|
||||||
|
let url = root.appendingPathComponent(rel)
|
||||||
|
let rootPath = root.standardizedFileURL.path
|
||||||
|
guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decisions of a sync pass, kept free of networking so they can be
|
||||||
|
/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its
|
||||||
|
/// helpers - the same rules, so a device behaves the same on either platform.
|
||||||
|
enum SyncDiff {
|
||||||
|
/// Diffs one synced path's remote manifest against the recorded state.
|
||||||
|
/// Callers own actually doing the GET/PUT/delete and updating state.
|
||||||
|
///
|
||||||
|
/// - A file with no recorded state is simply pulled.
|
||||||
|
/// - Server changed and device changed -> conflict (never silently overwrite).
|
||||||
|
/// - Only the server changed -> download. Only the device changed -> upload.
|
||||||
|
/// - Recorded but missing on the device -> pull it back (it's inside a path
|
||||||
|
/// the user asked to sync, so a vanished copy is repaired, not propagated).
|
||||||
|
/// - Recorded for this path but absent from a *successful* listing ->
|
||||||
|
/// deleted on the server, so the local copy goes too. (An unreachable
|
||||||
|
/// server never gets here - the caller skips the path instead.)
|
||||||
|
static func diffFolder(
|
||||||
|
entries: [SyncRemoteEntry],
|
||||||
|
state: [String: SyncFileState],
|
||||||
|
priorFolderFileIds: Set<String>,
|
||||||
|
syncRoot: URL
|
||||||
|
) -> [SyncAction] {
|
||||||
|
var actions: [SyncAction] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
for entry in entries where !entry.isFolder {
|
||||||
|
seen.insert(entry.fileId)
|
||||||
|
let rel = LocalFS.relative(entry.path)
|
||||||
|
let local = LocalFS.stat(syncRoot.appendingPathComponent(rel))
|
||||||
|
|
||||||
|
guard let prior = state[entry.fileId] else {
|
||||||
|
actions.append(.download(entry))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let serverChanged = entry.etag != prior.etag
|
||||||
|
let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false
|
||||||
|
|
||||||
|
if local == nil {
|
||||||
|
actions.append(.download(entry))
|
||||||
|
} else if serverChanged && localChanged {
|
||||||
|
actions.append(.conflict(entry, relPath: rel))
|
||||||
|
} else if serverChanged {
|
||||||
|
actions.append(.download(entry))
|
||||||
|
} else if localChanged {
|
||||||
|
actions.append(.upload(relPath: rel, fileId: entry.fileId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for fileId in priorFolderFileIds where !seen.contains(fileId) {
|
||||||
|
guard let prior = state[fileId] else { continue }
|
||||||
|
// Deleted on the server. If it was also edited here since the last sync
|
||||||
|
// that edit is the only copy left - keep it (untracked) instead of
|
||||||
|
// deleting it along with the rest.
|
||||||
|
if let local = LocalFS.stat(syncRoot.appendingPathComponent(prior.relPath)),
|
||||||
|
local.mtimeMs != prior.localMTime || local.size != prior.size
|
||||||
|
{
|
||||||
|
actions.append(.orphan(relPath: prior.relPath, fileId: fileId))
|
||||||
|
} else {
|
||||||
|
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded files that belong to the synced [path]: everything for the
|
||||||
|
/// root ("/"), else the file itself or anything under it - matched on a
|
||||||
|
/// path-component boundary, so "/Docs" never claims "/Docs2" or
|
||||||
|
/// "/Documents".
|
||||||
|
static func priorFileIds(state: [String: SyncFileState], path: String) -> Set<String> {
|
||||||
|
let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path))
|
||||||
|
return Set(
|
||||||
|
state.filter {
|
||||||
|
rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/")
|
||||||
|
}.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local paths (as spelled by the server) that more than one remote entry
|
||||||
|
/// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two
|
||||||
|
/// canonically-equivalent Unicode spellings. Downloading either would
|
||||||
|
/// overwrite the other, so callers skip them.
|
||||||
|
static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set<String> {
|
||||||
|
var byKey: [String: [String]] = [:]
|
||||||
|
for entry in entries {
|
||||||
|
let rel = LocalFS.relative(entry.path)
|
||||||
|
byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel)
|
||||||
|
}
|
||||||
|
// Compared as raw bytes: Swift's own `String` equality already treats
|
||||||
|
// canonically equivalent spellings as the same string, which is exactly
|
||||||
|
// the distinction the server preserves and the disk may not.
|
||||||
|
return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if every file in [fileIds] is still on disk exactly as recorded
|
||||||
|
/// (same size and mtime): nothing local to upload and nothing missing to
|
||||||
|
/// re-download. Pure local stat calls, no network.
|
||||||
|
static func localMatchesState(
|
||||||
|
state: [String: SyncFileState], fileIds: Set<String>, syncRoot: URL
|
||||||
|
) -> Bool {
|
||||||
|
fileIds.allSatisfy { id in
|
||||||
|
guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return local.size == s.size && local.mtimeMs == s.localMTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a local directory for every remote folder, so empty folders
|
||||||
|
/// exist on the device too.
|
||||||
|
static func mirrorFolders(
|
||||||
|
entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set<String> = []
|
||||||
|
) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) {
|
||||||
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
for entry in entries where entry.isFolder {
|
||||||
|
let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
// Skip anything unsafe or that would share a local folder with another
|
||||||
|
// remote folder (a/A on a case-insensitive volume).
|
||||||
|
guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue }
|
||||||
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes local directories under [rootRel] that no longer exist on the
|
||||||
|
/// server ([remoteFolders], relative paths). Only *empty* directories go:
|
||||||
|
/// by the time this runs the diff has already deleted the files that were
|
||||||
|
/// inside a deleted server folder, while a directory that still holds
|
||||||
|
/// something (a file this device created that hasn't synced) is left alone.
|
||||||
|
/// Never removes the sync root itself.
|
||||||
|
static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set<String>) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel)
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return }
|
||||||
|
|
||||||
|
func prune(_ dir: URL, rel: String) {
|
||||||
|
let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
|
||||||
|
for child in children {
|
||||||
|
let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||||
|
if childIsDir {
|
||||||
|
prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !rel.isEmpty, !remoteFolders.contains(rel),
|
||||||
|
let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty
|
||||||
|
else { return }
|
||||||
|
// rmdir, not removeItem: it refuses a directory that isn't empty, so a
|
||||||
|
// file that appeared since the check survives.
|
||||||
|
rmdir(dir.path)
|
||||||
|
}
|
||||||
|
prune(top, rel: rootRel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times
|
||||||
|
// are milliseconds since the epoch, like the Kotlin side, so the persisted
|
||||||
|
// state means the same thing on both platforms.
|
||||||
|
|
||||||
|
/// One file or folder the server reported in a PROPFIND.
|
||||||
|
struct SyncRemoteEntry: Equatable {
|
||||||
|
/// Path relative to the user's files root, no trailing slash ("/Docs/a.txt").
|
||||||
|
let path: String
|
||||||
|
let fileId: String
|
||||||
|
let etag: String
|
||||||
|
let lastModified: Int64
|
||||||
|
let size: Int64
|
||||||
|
let isFolder: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the last successful sync recorded for a file - the baseline a later
|
||||||
|
/// pass diffs the server and the device against.
|
||||||
|
struct SyncFileState: Codable, Equatable {
|
||||||
|
let relPath: String
|
||||||
|
let etag: String
|
||||||
|
let lastModified: Int64
|
||||||
|
let size: Int64
|
||||||
|
let localMTime: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configured sync root's etag at the end of its last clean, complete walk.
|
||||||
|
/// Nextcloud propagates a change to any descendant up through every ancestor's
|
||||||
|
/// etag, so an unchanged root etag means nothing beneath it changed.
|
||||||
|
struct SyncRootMarker: Codable, Equatable {
|
||||||
|
let etag: String
|
||||||
|
let fullWalkAt: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncAction: Equatable {
|
||||||
|
case download(SyncRemoteEntry)
|
||||||
|
case upload(relPath: String, fileId: String)
|
||||||
|
case delete(relPath: String, fileId: String)
|
||||||
|
case conflict(SyncRemoteEntry, relPath: String)
|
||||||
|
/// Deleted on the server, but edited on this device since the last sync:
|
||||||
|
/// the edited copy is the only one left, so it's kept (and simply no longer
|
||||||
|
/// tracked) instead of being deleted with the rest. Android deletes it.
|
||||||
|
case orphan(relPath: String, fileId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file changed both on the device and on the server since the last sync.
|
||||||
|
struct SyncConflict: Codable, Equatable {
|
||||||
|
let accountId: String
|
||||||
|
let fileId: String
|
||||||
|
let remotePath: String
|
||||||
|
let relPath: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a sync run (foreground or background) needs for one account.
|
||||||
|
/// Persisted in the Keychain - it carries the auth header.
|
||||||
|
struct SyncAccountConfig: Codable, Equatable {
|
||||||
|
let accountId: String
|
||||||
|
let serverUrl: String
|
||||||
|
let username: String
|
||||||
|
let authHeader: String
|
||||||
|
/// Remote paths (files or folders) to mirror.
|
||||||
|
var folders: [String]
|
||||||
|
var wifiOnly: Bool
|
||||||
|
/// Minutes between background syncs; nil = no background sync (manual and
|
||||||
|
/// in-app refresh only).
|
||||||
|
var intervalMinutes: Int?
|
||||||
|
/// Whether automatic runs may post a summary notification.
|
||||||
|
var notify: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncRunSummary: Equatable {
|
||||||
|
var downloaded = 0
|
||||||
|
var uploaded = 0
|
||||||
|
var deleted = 0
|
||||||
|
var conflicts: [SyncConflict] = []
|
||||||
|
|
||||||
|
var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server couldn't be reached or answered with an error (anything but a
|
||||||
|
/// clean 404). Distinct from "the path is genuinely gone": treating a network
|
||||||
|
/// blip as an empty listing made every previously synced file look deleted
|
||||||
|
/// server-side, and the diff then deleted the local copies. Callers skip the
|
||||||
|
/// affected path for this run instead.
|
||||||
|
struct SyncRemoteUnavailable: Error, CustomStringConvertible {
|
||||||
|
let message: String
|
||||||
|
var description: String { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded sync state exists but couldn't be read (corrupt, or an I/O
|
||||||
|
/// error). Treating that as "nothing recorded yet" would make every local
|
||||||
|
/// file look untracked and overwrite local edits with the server's copy, so
|
||||||
|
/// a run that hits it does nothing at all.
|
||||||
|
struct SyncStateUnreadable: Error, CustomStringConvertible {
|
||||||
|
let accountId: String
|
||||||
|
var description: String { "Sync state for \(accountId) is unreadable" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A mutex for `async` code: one holder at a time, waiters resume in order.
|
||||||
|
/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held
|
||||||
|
/// for the whole of any run that reads-then-rewrites a sync state map, which
|
||||||
|
/// is also what keeps `SyncStatusBus` - it tracks one account at a time -
|
||||||
|
/// coherent when several accounts are due.
|
||||||
|
///
|
||||||
|
/// Waiting is cancellable: a task that's cancelled while queued (a background
|
||||||
|
/// run whose time budget expired, a superseded "Sync now") leaves the queue
|
||||||
|
/// at once with `CancellationError` instead of sitting behind a long run.
|
||||||
|
final class AsyncMutex {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var locked = false
|
||||||
|
private var waiters: [(id: UUID, continuation: CheckedContinuation<Void, Error>)] = []
|
||||||
|
|
||||||
|
func withLock<T>(_ body: () async throws -> T) async throws -> T {
|
||||||
|
try await acquire()
|
||||||
|
defer { release() }
|
||||||
|
return try await body()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquire() async throws {
|
||||||
|
let id = UUID()
|
||||||
|
try await withTaskCancellationHandler {
|
||||||
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
|
lock.lock()
|
||||||
|
if Task.isCancelled {
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume(throwing: CancellationError())
|
||||||
|
} else if !locked {
|
||||||
|
locked = true
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume()
|
||||||
|
} else {
|
||||||
|
waiters.append((id, continuation))
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} onCancel: {
|
||||||
|
lock.lock()
|
||||||
|
if let index = waiters.firstIndex(where: { $0.id == id }) {
|
||||||
|
let waiter = waiters.remove(at: index)
|
||||||
|
lock.unlock()
|
||||||
|
waiter.continuation.resume(throwing: CancellationError())
|
||||||
|
} else {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func release() {
|
||||||
|
lock.lock()
|
||||||
|
if waiters.isEmpty {
|
||||||
|
locked = false
|
||||||
|
lock.unlock()
|
||||||
|
} else {
|
||||||
|
let next = waiters.removeFirst()
|
||||||
|
lock.unlock()
|
||||||
|
next.continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live sync status for the app: whether a run is going, which files are
|
||||||
|
/// transferring, and unresolved conflicts. In-memory only - what actually
|
||||||
|
/// answers "is this file synced" is the on-disk state; this carries just the
|
||||||
|
/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that
|
||||||
|
/// conflicts are kept per account (a file id is only unique within one).
|
||||||
|
final class SyncStatusBus {
|
||||||
|
struct Status: Equatable {
|
||||||
|
var accountId: String?
|
||||||
|
var syncing = false
|
||||||
|
var syncingFileIds: Set<String> = []
|
||||||
|
/// Every account's unresolved conflicts - readers filter by account.
|
||||||
|
var conflicts: [SyncConflict] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var current = Status()
|
||||||
|
|
||||||
|
/// Called (on whatever thread changed it) after every update.
|
||||||
|
var onChange: ((Status) -> Void)?
|
||||||
|
|
||||||
|
func snapshot() -> Status {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSyncing(accountId: String, _ syncing: Bool) {
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
$0.syncing = syncing
|
||||||
|
if !syncing { $0.syncingFileIds = [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) {
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addConflicts(accountId: String, _ new: [SyncConflict]) {
|
||||||
|
guard !new.isEmpty else { return }
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
for conflict in new
|
||||||
|
where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) {
|
||||||
|
$0.conflicts.append(conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeConflict(accountId: String, fileId: String) {
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
$0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(_ mutate: (inout Status) -> Void) {
|
||||||
|
lock.lock()
|
||||||
|
mutate(¤t)
|
||||||
|
let snapshot = current
|
||||||
|
lock.unlock()
|
||||||
|
onChange?(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One account's sync pass: walk each configured path, diff it against the
|
||||||
|
/// recorded state, transfer, and record the result. The iOS port of Android's
|
||||||
|
/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same
|
||||||
|
/// safeguards - and a few more, found in review:
|
||||||
|
///
|
||||||
|
/// - If the server can't be reached for a path - or answers with something
|
||||||
|
/// that isn't a complete, plausible listing - that path is skipped for this
|
||||||
|
/// run, never diffed against an empty or partial listing, which would look
|
||||||
|
/// like files were deleted and wipe the local copies.
|
||||||
|
/// - If the recorded state can't be read the run does nothing at all (it
|
||||||
|
/// would otherwise treat every local file as untracked and overwrite local
|
||||||
|
/// edits with the server's copies).
|
||||||
|
/// - Paths the server sends are checked to stay inside the mirror, and two
|
||||||
|
/// remote files that would land on the same local file are both skipped.
|
||||||
|
/// - A file deleted on the server but edited here is kept, not deleted.
|
||||||
|
/// - A root's etag is only remembered once its whole path synced cleanly, and
|
||||||
|
/// only if the state it relies on was actually saved.
|
||||||
|
struct SyncRunner {
|
||||||
|
let client: DavSyncClient
|
||||||
|
let store: SyncStore
|
||||||
|
let bus: SyncStatusBus
|
||||||
|
var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }
|
||||||
|
|
||||||
|
/// Even when a root's etag hasn't changed, walk it in full at least this
|
||||||
|
/// often: etag propagation isn't reliable everywhere (external storage
|
||||||
|
/// mounts in particular), so this bounds how stale a mirror can get.
|
||||||
|
static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/// State is flushed to disk after this many transfers.
|
||||||
|
static let saveEvery = 20
|
||||||
|
|
||||||
|
/// [force] is an explicit "Sync now" / newly added path: always a full
|
||||||
|
/// walk, never trusting the root-etag shortcut.
|
||||||
|
func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary {
|
||||||
|
var summary = SyncRunSummary()
|
||||||
|
guard !config.folders.isEmpty else { return summary }
|
||||||
|
let accountId = config.accountId
|
||||||
|
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||||
|
let syncRoot = store.syncRoot(accountId: accountId)
|
||||||
|
|
||||||
|
var state: [String: SyncFileState]
|
||||||
|
do {
|
||||||
|
state = try store.loadState(accountId: accountId)
|
||||||
|
} catch {
|
||||||
|
NSLog("[Sync] %@ - skipping this run", String(describing: error))
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
var markers = store.loadRootMarkers(accountId: accountId)
|
||||||
|
var sinceSave = 0
|
||||||
|
|
||||||
|
bus.setSyncing(accountId: accountId, true)
|
||||||
|
defer {
|
||||||
|
// State first, then the markers that depend on it, then announce.
|
||||||
|
if store.saveState(accountId: accountId, state) {
|
||||||
|
markers = markers.filter { config.folders.contains($0.key) }
|
||||||
|
store.saveRootMarkers(accountId: accountId, markers)
|
||||||
|
} else {
|
||||||
|
NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId)
|
||||||
|
}
|
||||||
|
bus.setSyncing(accountId: accountId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferred() {
|
||||||
|
sinceSave += 1
|
||||||
|
if sinceSave >= Self.saveEvery {
|
||||||
|
if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") }
|
||||||
|
sinceSave = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in config.folders {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
do {
|
||||||
|
// A configured path can be a file or a folder - check which before
|
||||||
|
// deciding whether to walk it or just diff the single item.
|
||||||
|
let selfEntry = try await client.propfindSelf(creds, path: path)
|
||||||
|
let priorIds = SyncDiff.priorFileIds(state: state, path: path)
|
||||||
|
|
||||||
|
// Cheap "did anything change?" check: an unchanged root etag means
|
||||||
|
// nothing beneath it changed on the server, and if every local file
|
||||||
|
// is also still as recorded there's nothing to upload or re-download
|
||||||
|
// either - skip the walk (one Depth-0 PROPFIND instead of one per
|
||||||
|
// subfolder).
|
||||||
|
if !force, let root = selfEntry, root.isFolder, let marker = markers[path],
|
||||||
|
marker.etag == root.etag,
|
||||||
|
now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs,
|
||||||
|
!priorIds.isEmpty,
|
||||||
|
SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server says this synced path no longer exists (a clean 404, not
|
||||||
|
// a network error): flag it so the app drops it from its synced list.
|
||||||
|
store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil)
|
||||||
|
|
||||||
|
var entries: [SyncRemoteEntry] = []
|
||||||
|
if let root = selfEntry {
|
||||||
|
if root.isFolder {
|
||||||
|
entries = try await client.walk(creds, root: path)
|
||||||
|
} else {
|
||||||
|
entries = [root]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var pathClean = true
|
||||||
|
let collisions = SyncDiff.collidingRelPaths(entries)
|
||||||
|
|
||||||
|
let actions = SyncDiff.diffFolder(
|
||||||
|
entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot)
|
||||||
|
for action in actions {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
switch action {
|
||||||
|
case .download(let entry):
|
||||||
|
let rel = LocalFS.relative(entry.path)
|
||||||
|
guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else {
|
||||||
|
NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel)
|
||||||
|
pathClean = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true)
|
||||||
|
let ok = try await client.download(creds, remotePath: entry.path, to: dest)
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false)
|
||||||
|
if ok {
|
||||||
|
let local = LocalFS.stat(dest)
|
||||||
|
state[entry.fileId] = SyncFileState(
|
||||||
|
relPath: rel, etag: entry.etag, lastModified: entry.lastModified,
|
||||||
|
size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0)
|
||||||
|
summary.downloaded += 1
|
||||||
|
transferred()
|
||||||
|
} else {
|
||||||
|
pathClean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
case .upload(let rel, let fileId):
|
||||||
|
guard let local = LocalFS.resolve(rel, in: syncRoot) else {
|
||||||
|
pathClean = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: fileId, true)
|
||||||
|
var ok = false
|
||||||
|
if FileManager.default.fileExists(atPath: local.path) {
|
||||||
|
ok = try await client.upload(creds, remotePath: "/" + rel, from: local)
|
||||||
|
}
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: fileId, false)
|
||||||
|
if ok, let stat = LocalFS.stat(local) {
|
||||||
|
// Our own PUT changed the file's etag; record the new one, or
|
||||||
|
// the next pass would see "server changed" and re-download it.
|
||||||
|
// If it can't be read back, keep the old one and let the root
|
||||||
|
// be walked for real next time.
|
||||||
|
let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil
|
||||||
|
if fresh == nil { pathClean = false }
|
||||||
|
state[fileId] = SyncFileState(
|
||||||
|
relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "",
|
||||||
|
lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs)
|
||||||
|
summary.uploaded += 1
|
||||||
|
transferred()
|
||||||
|
} else {
|
||||||
|
pathClean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
case .delete(let rel, let fileId):
|
||||||
|
// Only counts as a removal if a local copy existed and went;
|
||||||
|
// dropping stale state for a file never on disk isn't news.
|
||||||
|
if let local = LocalFS.resolve(rel, in: syncRoot),
|
||||||
|
FileManager.default.fileExists(atPath: local.path),
|
||||||
|
(try? FileManager.default.removeItem(at: local)) != nil
|
||||||
|
{
|
||||||
|
summary.deleted += 1
|
||||||
|
}
|
||||||
|
state[fileId] = nil
|
||||||
|
|
||||||
|
case .orphan(_, let fileId):
|
||||||
|
// Deleted on the server but edited here: the edit stays on the
|
||||||
|
// device, no longer tracked.
|
||||||
|
state[fileId] = nil
|
||||||
|
|
||||||
|
case .conflict(let entry, let rel):
|
||||||
|
summary.conflicts.append(
|
||||||
|
SyncConflict(
|
||||||
|
accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel,
|
||||||
|
name: (rel as NSString).lastPathComponent))
|
||||||
|
pathClean = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the folder structure itself: empty folders created on the
|
||||||
|
// server appear on the device, and folders deleted on the server
|
||||||
|
// (whose files the diff has already removed) don't linger.
|
||||||
|
if selfEntry == nil || selfEntry?.isFolder == true {
|
||||||
|
let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
if selfEntry != nil {
|
||||||
|
SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions)
|
||||||
|
}
|
||||||
|
var remoteFolders = Set(
|
||||||
|
entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) })
|
||||||
|
if selfEntry != nil { remoteFolders.insert(rootRel) }
|
||||||
|
SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let root = selfEntry, root.isFolder, pathClean {
|
||||||
|
markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now())
|
||||||
|
} else {
|
||||||
|
markers[path] = nil
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
markers[path] = nil // cut short: make the next run walk it for real
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
// Couldn't reach the server for this path (or it answered with
|
||||||
|
// nothing usable): leave its state and marker alone and try again
|
||||||
|
// next run.
|
||||||
|
NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !summary.conflicts.isEmpty {
|
||||||
|
bus.addConflicts(accountId: accountId, summary.conflicts)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the user's choice for one conflicted file - `local` uploads the
|
||||||
|
/// device's copy over the server's, `server` downloads the server's over
|
||||||
|
/// the device's - then refreshes the recorded etag so the next pass doesn't
|
||||||
|
/// immediately re-flag it. Returns whether it worked.
|
||||||
|
func resolveConflict(
|
||||||
|
_ conflict: SyncConflict, resolution: String, creds: SyncCredentials
|
||||||
|
) async -> Bool {
|
||||||
|
// An unreadable baseline must not be overwritten with a one-file one.
|
||||||
|
guard var state = try? store.loadState(accountId: conflict.accountId),
|
||||||
|
let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId))
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
var ok = false
|
||||||
|
do {
|
||||||
|
switch resolution {
|
||||||
|
case "local":
|
||||||
|
if FileManager.default.fileExists(atPath: local.path) {
|
||||||
|
ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local)
|
||||||
|
}
|
||||||
|
case "server":
|
||||||
|
ok = try await client.download(creds, remotePath: conflict.remotePath, to: local)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard ok else { return false }
|
||||||
|
|
||||||
|
let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil
|
||||||
|
let stat = LocalFS.stat(local)
|
||||||
|
state[conflict.fileId] = SyncFileState(
|
||||||
|
relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0,
|
||||||
|
size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0)
|
||||||
|
store.saveState(accountId: conflict.accountId, state)
|
||||||
|
bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The on-device side of sync: the mirrored files and the per-account sync
|
||||||
|
/// state. Laid out under one base directory (Application Support, which is
|
||||||
|
/// also where Dart's `SyncService.baseDirectory()` looks for the mirror):
|
||||||
|
///
|
||||||
|
/// <base>/sync/<accountId>/... the mirrored files
|
||||||
|
/// <base>/sync-state/<accountId>/*.json state, root markers, missing roots
|
||||||
|
///
|
||||||
|
/// Plain JSON files rather than `UserDefaults`: the state map grows with the
|
||||||
|
/// number of synced files. Not thread-safe by itself - the sync runner holds
|
||||||
|
/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`).
|
||||||
|
final class SyncStore {
|
||||||
|
/// Bumped whenever a walk starts doing something new (e.g. mirroring empty
|
||||||
|
/// folders) so markers recorded by an older version - which would make the
|
||||||
|
/// next run skip the walk - are ignored once. Same value as Android.
|
||||||
|
static let rootsVersion = 2
|
||||||
|
|
||||||
|
static let shared = SyncStore(base: defaultBase)
|
||||||
|
|
||||||
|
static var defaultBase: URL {
|
||||||
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
let base: URL
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
|
init(base: URL) {
|
||||||
|
self.base = base
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locations
|
||||||
|
|
||||||
|
/// The account's mirror folder, created on demand. Marked excluded from
|
||||||
|
/// iCloud/device backups: it's a re-downloadable copy of server data.
|
||||||
|
func syncRoot(accountId: String) -> URL {
|
||||||
|
let top = base.appendingPathComponent("sync", isDirectory: true)
|
||||||
|
let root = top.appendingPathComponent(accountId, isDirectory: true)
|
||||||
|
try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
var values = URLResourceValues()
|
||||||
|
values.isExcludedFromBackup = true
|
||||||
|
var topMutable = top
|
||||||
|
try? topMutable.setResourceValues(values)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stateDirectory(accountId: String) -> URL {
|
||||||
|
let dir = base.appendingPathComponent("sync-state", isDirectory: true)
|
||||||
|
.appendingPathComponent(accountId, isDirectory: true)
|
||||||
|
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func file(_ accountId: String, _ name: String) -> URL {
|
||||||
|
stateDirectory(accountId: accountId).appendingPathComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-file state
|
||||||
|
|
||||||
|
/// The recorded baseline - empty if nothing has been recorded yet, and
|
||||||
|
/// *throws* if there is a file that can't be read or decoded (see
|
||||||
|
/// `SyncStateUnreadable`).
|
||||||
|
func loadState(accountId: String) throws -> [String: SyncFileState] {
|
||||||
|
let url = file(accountId, "state.json")
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else { return [:] }
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url))
|
||||||
|
} catch {
|
||||||
|
throw SyncStateUnreadable(accountId: accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False if the state couldn't be written - callers must not then record
|
||||||
|
/// anything that assumes it was.
|
||||||
|
@discardableResult
|
||||||
|
func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool {
|
||||||
|
write(state, to: file(accountId, "state.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Root markers
|
||||||
|
|
||||||
|
private struct MarkersFile: Codable {
|
||||||
|
let version: Int
|
||||||
|
let markers: [String: SyncRootMarker]
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] {
|
||||||
|
guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")),
|
||||||
|
stored.version == Self.rootsVersion
|
||||||
|
else { return [:] }
|
||||||
|
return stored.markers
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool {
|
||||||
|
write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Roots the server says are gone
|
||||||
|
|
||||||
|
func loadMissingRoots(accountId: String) -> Set<String> {
|
||||||
|
Set(read([String].self, from: file(accountId, "missing.json")) ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRootMissing(accountId: String, path: String, missing: Bool) {
|
||||||
|
var current = loadMissingRoots(accountId: accountId)
|
||||||
|
let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil)
|
||||||
|
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Removing a path's mirror
|
||||||
|
|
||||||
|
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
|
||||||
|
/// forgets its entries - called when the user turns sync off for that path.
|
||||||
|
/// Without clearing the state too, a later re-add would see the (now
|
||||||
|
/// missing) local file as "deleted, server unchanged" and not pull it back.
|
||||||
|
func removeLocalSync(accountId: String, path: String) {
|
||||||
|
var cleanPath = path.trimmingCharacters(in: .whitespaces)
|
||||||
|
if cleanPath.hasPrefix("/") { cleanPath.removeFirst() }
|
||||||
|
while cleanPath.hasSuffix("/") { cleanPath.removeLast() }
|
||||||
|
let prefix = cleanPath.isEmpty ? "" : cleanPath + "/"
|
||||||
|
|
||||||
|
// An unreadable baseline is left alone rather than overwritten with a
|
||||||
|
// partial one; the next run reports it and does nothing.
|
||||||
|
if var state = try? loadState(accountId: accountId) {
|
||||||
|
// The sync root itself ("/") covers every recorded file.
|
||||||
|
for (fileId, entry) in state
|
||||||
|
where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) {
|
||||||
|
state[fileId] = nil
|
||||||
|
}
|
||||||
|
saveState(accountId: accountId, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false)
|
||||||
|
setRootMissing(accountId: accountId, path: cleanPath, missing: false)
|
||||||
|
|
||||||
|
// A root marker for this path (or an ancestor/descendant that also covers
|
||||||
|
// some of what was just deleted) would make the next background pass
|
||||||
|
// think nothing changed and skip re-downloading.
|
||||||
|
let removedPath = "/" + cleanPath
|
||||||
|
var markers = loadRootMarkers(accountId: accountId)
|
||||||
|
let stale = markers.keys.filter { key in
|
||||||
|
var k = key
|
||||||
|
while k.hasSuffix("/") { k.removeLast() }
|
||||||
|
return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/")
|
||||||
|
|| removedPath.hasPrefix(k + "/")
|
||||||
|
}
|
||||||
|
if !stale.isEmpty {
|
||||||
|
stale.forEach { markers[$0] = nil }
|
||||||
|
saveRootMarkers(accountId: accountId, markers)
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = syncRoot(accountId: accountId)
|
||||||
|
let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath)
|
||||||
|
if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) }
|
||||||
|
if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
||||||
|
guard let data = try? JSONEncoder().encode(value) else { return false }
|
||||||
|
do {
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Runner
|
||||||
|
|
||||||
|
/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1,
|
||||||
|
/// GET, PUT) for the sync engine to run end to end against - with ancestor
|
||||||
|
/// etags bumping on every change, like the real thing.
|
||||||
|
final class FakeDav: URLProtocol {
|
||||||
|
struct Node {
|
||||||
|
var isFolder: Bool
|
||||||
|
var data = Data()
|
||||||
|
var etag: String
|
||||||
|
var fileId: String
|
||||||
|
var mtime = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var nodes: [String: Node] = [:]
|
||||||
|
static var failAll = false
|
||||||
|
/// A folder whose PROPFIND answers 500 while everything else works - for
|
||||||
|
/// proving a half-built manifest is never trusted.
|
||||||
|
static var failPropfindPath: String?
|
||||||
|
/// Answer PROPFIND with a 207 whose XML is cut off mid-way.
|
||||||
|
static var truncatePropfind = false
|
||||||
|
static var log: [String] = []
|
||||||
|
private static var counter = 0
|
||||||
|
static let user = "alice"
|
||||||
|
private static let base = "/remote.php/dav/files/alice"
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")]
|
||||||
|
failAll = false
|
||||||
|
failPropfindPath = nil
|
||||||
|
truncatePropfind = false
|
||||||
|
log = []
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextTag() -> String {
|
||||||
|
counter += 1
|
||||||
|
return "tag\(counter)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parent(of path: String) -> String {
|
||||||
|
let p = (path as NSString).deletingLastPathComponent
|
||||||
|
return p.isEmpty ? "/" : p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bumps [path]'s etag and every ancestor's.
|
||||||
|
private static func touch(_ path: String) {
|
||||||
|
var p = path
|
||||||
|
while true {
|
||||||
|
nodes[p]?.etag = nextTag()
|
||||||
|
if p == "/" { break }
|
||||||
|
p = parent(of: p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Test-side helpers
|
||||||
|
|
||||||
|
static func mkdir(_ path: String) {
|
||||||
|
var parts: [String] = []
|
||||||
|
for comp in path.split(separator: "/") {
|
||||||
|
parts.append(String(comp))
|
||||||
|
let p = "/" + parts.joined(separator: "/")
|
||||||
|
if nodes[p] == nil {
|
||||||
|
nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)")
|
||||||
|
touch(parent(of: p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func put(_ path: String, _ text: String) {
|
||||||
|
mkdir(parent(of: path))
|
||||||
|
var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())")
|
||||||
|
node.data = Data(text.utf8)
|
||||||
|
nodes[path] = node
|
||||||
|
touch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remove(_ path: String) {
|
||||||
|
for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil }
|
||||||
|
touch(parent(of: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func text(_ path: String) -> String? {
|
||||||
|
nodes[path].flatMap { String(data: $0.data, encoding: .utf8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: URLProtocol
|
||||||
|
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
override func stopLoading() {}
|
||||||
|
|
||||||
|
override func startLoading() {
|
||||||
|
let request = self.request
|
||||||
|
let method = request.httpMethod ?? "GET"
|
||||||
|
let depth = request.value(forHTTPHeaderField: "Depth") ?? "-"
|
||||||
|
var path = (request.url?.path ?? "").removingPercentEncoding ?? ""
|
||||||
|
if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) }
|
||||||
|
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
|
||||||
|
if path.isEmpty { path = "/" }
|
||||||
|
Self.log.append("\(method) \(depth) \(path)")
|
||||||
|
|
||||||
|
func send(_ status: Int, _ body: Data = Data()) {
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||||
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
|
client?.urlProtocol(self, didLoad: body)
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self.failAll { return send(503) }
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "PROPFIND":
|
||||||
|
if path == Self.failPropfindPath { return send(500) }
|
||||||
|
guard Self.nodes[path] != nil else { return send(404) }
|
||||||
|
var paths = [path]
|
||||||
|
if depth == "1" {
|
||||||
|
paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted()
|
||||||
|
}
|
||||||
|
var body = Self.multistatus(paths)
|
||||||
|
// Only folder listings: a cut-off *listing* still holds a few complete
|
||||||
|
// responses, which is the dangerous case (a partial manifest).
|
||||||
|
if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) }
|
||||||
|
send(207, Data(body.utf8))
|
||||||
|
case "GET":
|
||||||
|
guard let node = Self.nodes[path], !node.isFolder else { return send(404) }
|
||||||
|
send(200, node.data)
|
||||||
|
case "PUT":
|
||||||
|
var data = Data()
|
||||||
|
if let stream = request.httpBodyStream {
|
||||||
|
stream.open()
|
||||||
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||||
|
while stream.hasBytesAvailable {
|
||||||
|
let n = stream.read(&buffer, maxLength: buffer.count)
|
||||||
|
if n <= 0 { break }
|
||||||
|
data.append(buffer, count: n)
|
||||||
|
}
|
||||||
|
stream.close()
|
||||||
|
} else if let body = request.httpBody {
|
||||||
|
data = body
|
||||||
|
}
|
||||||
|
Self.mkdir(Self.parent(of: path))
|
||||||
|
var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())")
|
||||||
|
node.data = data
|
||||||
|
Self.nodes[path] = node
|
||||||
|
Self.touch(path)
|
||||||
|
send(201)
|
||||||
|
default:
|
||||||
|
send(405)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let httpDate: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static func multistatus(_ paths: [String]) -> String {
|
||||||
|
let responses = paths.map { p -> String in
|
||||||
|
let node = nodes[p]!
|
||||||
|
let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/")
|
||||||
|
let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "")
|
||||||
|
return """
|
||||||
|
<d:response><d:href>\(href)</d:href><d:propstat><d:prop>\
|
||||||
|
<d:getlastmodified>\(httpDate.string(from: node.mtime))</d:getlastmodified>\
|
||||||
|
<d:getcontentlength>\(node.data.count)</d:getcontentlength>\
|
||||||
|
<d:resourcetype>\(node.isFolder ? "<d:collection/>" : "")</d:resourcetype>\
|
||||||
|
<d:getetag>"\(node.etag)"</d:getetag><oc:fileid>\(node.fileId)</oc:fileid>\
|
||||||
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
"""
|
||||||
|
}.joined()
|
||||||
|
return """
|
||||||
|
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">\(responses)</d:multistatus>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SyncEngineTests: XCTestCase {
|
||||||
|
private var store: SyncStore!
|
||||||
|
private var bus: SyncStatusBus!
|
||||||
|
private var runner: SyncRunner!
|
||||||
|
private var fakeSession: URLSession!
|
||||||
|
private var clock: Int64 = 1_700_000_000_000
|
||||||
|
private let config = SyncAccountConfig(
|
||||||
|
accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x",
|
||||||
|
folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false)
|
||||||
|
private var creds: SyncCredentials {
|
||||||
|
SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
FakeDav.reset()
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
||||||
|
addTeardownBlock { try? FileManager.default.removeItem(at: base) }
|
||||||
|
store = SyncStore(base: base)
|
||||||
|
bus = SyncStatusBus()
|
||||||
|
|
||||||
|
let sessionConfig = URLSessionConfiguration.ephemeral
|
||||||
|
sessionConfig.protocolClasses = [FakeDav.self]
|
||||||
|
fakeSession = URLSession(configuration: sessionConfig)
|
||||||
|
runner = SyncRunner(
|
||||||
|
client: DavSyncClient(session: fakeSession),
|
||||||
|
store: store, bus: bus, now: { [unowned self] in self.clock })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func state() -> [String: SyncFileState] {
|
||||||
|
(try? store.loadState(accountId: "acct")) ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mirror: URL { store.syncRoot(accountId: "acct") }
|
||||||
|
private func local(_ rel: String) -> String? {
|
||||||
|
try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8)
|
||||||
|
}
|
||||||
|
private func exists(_ rel: String) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path)
|
||||||
|
}
|
||||||
|
private func editLocally(_ rel: String, _ text: String) throws {
|
||||||
|
let url = mirror.appendingPathComponent(rel)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count }
|
||||||
|
|
||||||
|
private func seedServer() {
|
||||||
|
FakeDav.put("/Docs/a.txt", "alpha")
|
||||||
|
FakeDav.put("/Docs/sub/b.txt", "bravo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The happy path
|
||||||
|
|
||||||
|
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
|
||||||
|
seedServer()
|
||||||
|
FakeDav.mkdir("/Docs/empty")
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
|
||||||
|
XCTAssertEqual(summary.downloaded, 2)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored")
|
||||||
|
XCTAssertEqual(state().count, 2)
|
||||||
|
XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
|
||||||
|
XCTAssertFalse(bus.snapshot().syncing, "announced as finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnchangedServerIsOneCheapRequestNotAWalk() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
let walks = depth1Requests()
|
||||||
|
|
||||||
|
FakeDav.log = []
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(summary.changedAnything)
|
||||||
|
XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)")
|
||||||
|
XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForceAlwaysWalks() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.log = []
|
||||||
|
_ = await runner.run(config, force: true)
|
||||||
|
XCTAssertGreaterThan(depth1Requests(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShortcutExpiresAfterTheMaxAge() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
clock += SyncRunner.fullWalkMaxAgeMs + 1
|
||||||
|
FakeDav.log = []
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerChangeIsDownloaded() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "alpha v2")
|
||||||
|
FakeDav.put("/Docs/new.txt", "fresh")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 2)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha v2")
|
||||||
|
XCTAssertEqual(local("Docs/new.txt"), "fresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try editLocally("Docs/a.txt", "alpha edited on phone")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.uploaded, 1)
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone")
|
||||||
|
|
||||||
|
let again = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileDeletedLocallyIsPulledBack() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt"))
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflicts
|
||||||
|
|
||||||
|
func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"])
|
||||||
|
XCTAssertEqual(summary.conflicts.first?.name, "a.txt")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched")
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched")
|
||||||
|
XCTAssertEqual(bus.snapshot().conflicts.count, 1)
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||||
|
|
||||||
|
let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds)
|
||||||
|
XCTAssertTrue(ok)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "server edit")
|
||||||
|
XCTAssertTrue(bus.snapshot().conflicts.isEmpty)
|
||||||
|
|
||||||
|
let next = await runner.run(config, force: false)
|
||||||
|
XCTAssertTrue(next.conflicts.isEmpty)
|
||||||
|
XCTAssertFalse(next.changedAnything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveWithLocalCopyUploadsIt() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||||
|
|
||||||
|
let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds)
|
||||||
|
XCTAssertTrue(ok)
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit")
|
||||||
|
let next = await runner.run(config, force: false)
|
||||||
|
XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUnknownResolutionDoesNothing() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||||
|
|
||||||
|
let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds)
|
||||||
|
XCTAssertFalse(ok)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "phone edit")
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Deletes (the dangerous part)
|
||||||
|
|
||||||
|
func testFileDeletedOnTheServerIsRemovedLocally() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.remove("/Docs/a.txt")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.remove("/Docs/sub")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("Docs/sub/b.txt"))
|
||||||
|
XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned")
|
||||||
|
XCTAssertTrue(exists("Docs/a.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUnreachableServerNeverDeletesAnything() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
let before = state()
|
||||||
|
|
||||||
|
FakeDav.failAll = true
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertEqual(state(), before)
|
||||||
|
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'")
|
||||||
|
|
||||||
|
FakeDav.failAll = false
|
||||||
|
let recovered = await runner.run(config, force: true)
|
||||||
|
XCTAssertFalse(recovered.changedAnything)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
// Only the subfolder listing breaks: a half-built manifest must not make
|
||||||
|
// sub/b.txt look deleted.
|
||||||
|
FakeDav.nodes["/Docs/sub"]?.etag = "changed"
|
||||||
|
FakeDav.nodes["/Docs"]?.etag = "changed"
|
||||||
|
FakeDav.failPropfindPath = "/Docs/sub"
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertEqual(summary.deleted, 0)
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.remove("/Docs")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 2)
|
||||||
|
XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"])
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Single files, scoping, housekeeping
|
||||||
|
|
||||||
|
func testASyncedSingleFilePath() async {
|
||||||
|
FakeDav.put("/Notes/todo.txt", "buy milk")
|
||||||
|
var single = config
|
||||||
|
single.folders = ["/Notes/todo.txt"]
|
||||||
|
|
||||||
|
let summary = await runner.run(single, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1)
|
||||||
|
XCTAssertEqual(local("Notes/todo.txt"), "buy milk")
|
||||||
|
FakeDav.put("/Notes/todo.txt", "buy milk and eggs")
|
||||||
|
_ = await runner.run(single, force: false)
|
||||||
|
XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncOnlyTouchesTheConfiguredPaths() async {
|
||||||
|
seedServer()
|
||||||
|
FakeDav.put("/Other/x.txt", "not synced")
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(exists("Other/x.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNothingConfiguredDoesNothing() async {
|
||||||
|
seedServer()
|
||||||
|
var empty = config
|
||||||
|
empty.folders = []
|
||||||
|
let summary = await runner.run(empty, force: true)
|
||||||
|
XCTAssertEqual(summary, SyncRunSummary())
|
||||||
|
XCTAssertTrue(FakeDav.log.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
store.removeLocalSync(accountId: "acct", path: "/Docs/sub")
|
||||||
|
|
||||||
|
XCTAssertFalse(exists("Docs/sub/b.txt"))
|
||||||
|
XCTAssertTrue(exists("Docs/a.txt"))
|
||||||
|
XCTAssertEqual(state().count, 1)
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1)
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStatusMarksFilesAsSyncingDuringTheRun() async {
|
||||||
|
seedServer()
|
||||||
|
var seen = Set<String>()
|
||||||
|
bus.onChange = { seen.formUnion($0.syncingFileIds) }
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(seen.count, 2, "each transferring file was announced")
|
||||||
|
XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStaleMarkersForRemovedPathsAreDropped() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
var other = config
|
||||||
|
other.folders = ["/Other"]
|
||||||
|
FakeDav.mkdir("/Other")
|
||||||
|
_ = await runner.run(other, force: false)
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parsing
|
||||||
|
|
||||||
|
func testParserReadsEtagFileIdSizeAndDates() {
|
||||||
|
let xml = Data(
|
||||||
|
"""
|
||||||
|
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype><d:getetag>"abc123"</d:getetag>
|
||||||
|
<oc:fileid>7</oc:fileid></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf</d:href><d:propstat><d:prop>
|
||||||
|
<d:getlastmodified>Tue, 14 Nov 2023 22:13:20 GMT</d:getlastmodified>
|
||||||
|
<d:getcontentlength>2048</d:getcontentlength><d:resourcetype/>
|
||||||
|
<d:getetag>"e1"</d:getetag><oc:fileid>9</oc:fileid></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""".utf8)
|
||||||
|
let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true)
|
||||||
|
XCTAssertEqual(entries.count, 1, "the folder itself is skipped")
|
||||||
|
XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf")
|
||||||
|
XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped")
|
||||||
|
XCTAssertEqual(entries[0].fileId, "9")
|
||||||
|
XCTAssertEqual(entries[0].size, 2048)
|
||||||
|
XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000)
|
||||||
|
XCTAssertFalse(entries[0].isFolder)
|
||||||
|
|
||||||
|
let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false)
|
||||||
|
XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"])
|
||||||
|
XCTAssertTrue(all[0].isFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParserRefusesAnythingThatIsNotACompleteAnswer() {
|
||||||
|
func entries(_ xml: String) throws -> [SyncRemoteEntry] {
|
||||||
|
try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(try entries("nope"), "not XML")
|
||||||
|
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php"), "truncated")
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try entries(
|
||||||
|
"<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php/dav/files/alice/Docs/a.txt</d:href></d:response>"
|
||||||
|
+ "<d:response><d:href>/remote.php/dav/files/alice/Docs/b"),
|
||||||
|
"one complete response, then cut off: a partial manifest")
|
||||||
|
XCTAssertThrowsError(try entries("<html><body>Maintenance</body></html>"), "well-formed but not a multistatus")
|
||||||
|
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>"), "a 207 always has the resource itself")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() {
|
||||||
|
func parse(_ href: String) throws -> [SyncRemoteEntry] {
|
||||||
|
let xml = "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>\(href)</d:href><d:propstat><d:prop><d:resourcetype/></d:prop></d:propstat></d:response></d:multistatus>"
|
||||||
|
return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false)
|
||||||
|
}
|
||||||
|
XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt"))
|
||||||
|
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component")
|
||||||
|
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot")
|
||||||
|
XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root")
|
||||||
|
XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Review findings: each of these used to lose or misplace data
|
||||||
|
|
||||||
|
func testATruncatedAnswerIsNotAManifest() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.truncatePropfind = true
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeletedOnTheServerButEditedHereIsKept() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try editLocally("Docs/a.txt", "my only copy of this edit")
|
||||||
|
FakeDav.remove("/Docs/a.txt")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 0)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit")
|
||||||
|
XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked")
|
||||||
|
|
||||||
|
let again = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit", "and not touched later either")
|
||||||
|
XCTAssertFalse(again.changedAnything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncingTheWholeAccountNoticesServerDeletions() async {
|
||||||
|
FakeDav.put("/a.txt", "top")
|
||||||
|
FakeDav.put("/sub/b.txt", "nested")
|
||||||
|
var everything = config
|
||||||
|
everything.folders = ["/"]
|
||||||
|
|
||||||
|
_ = await runner.run(everything, force: false)
|
||||||
|
XCTAssertEqual(local("a.txt"), "top")
|
||||||
|
FakeDav.log = []
|
||||||
|
_ = await runner.run(everything, force: false)
|
||||||
|
XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too")
|
||||||
|
|
||||||
|
FakeDav.remove("/a.txt")
|
||||||
|
let summary = await runner.run(everything, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("a.txt"))
|
||||||
|
XCTAssertTrue(exists("sub/b.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPathNeverClaimsASiblingWithTheSamePrefix() {
|
||||||
|
let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) }
|
||||||
|
let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")]
|
||||||
|
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"])
|
||||||
|
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"])
|
||||||
|
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async {
|
||||||
|
FakeDav.put("/Docs/a.txt", "lower")
|
||||||
|
FakeDav.put("/Docs/A.txt", "upper")
|
||||||
|
FakeDav.put("/Docs/fine.txt", "ok")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1, "only the file with no collision")
|
||||||
|
XCTAssertEqual(local("Docs/fine.txt"), "ok")
|
||||||
|
XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other")
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean")
|
||||||
|
let unicode = SyncDiff.collidingRelPaths([
|
||||||
|
SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false),
|
||||||
|
SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false),
|
||||||
|
])
|
||||||
|
XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"),
|
||||||
|
"canonically equivalent Unicode spellings collide too")
|
||||||
|
XCTAssertTrue(
|
||||||
|
SyncDiff.collidingRelPaths([
|
||||||
|
SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false)
|
||||||
|
]).isEmpty, "one spelling is never a collision")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerPathsCanNeverLeaveTheMirror() {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)")
|
||||||
|
XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("../outside.txt", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("./x", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("/", in: root))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUnreadableStateMakesTheRunDoNothing() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try editLocally("Docs/a.txt", "unsynced local edit")
|
||||||
|
let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json")
|
||||||
|
try Data("{ this is not json".utf8).write(to: stateFile)
|
||||||
|
FakeDav.log = []
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertEqual(summary, SyncRunSummary())
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten")
|
||||||
|
XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server")
|
||||||
|
XCTAssertThrowsError(try store.loadState(accountId: "acct"))
|
||||||
|
XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoStateYetIsJustAFirstSyncNotAnError() throws {
|
||||||
|
XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws {
|
||||||
|
FakeDav.put("/Docs/a.txt", "server")
|
||||||
|
let client = DavSyncClient(session: fakeSession)
|
||||||
|
let folder = mirror.appendingPathComponent("Docs/a.txt")
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder)
|
||||||
|
XCTAssertFalse(ok, "a folder is not replaced by a file")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside")
|
||||||
|
|
||||||
|
let file = mirror.appendingPathComponent("Docs/b.txt")
|
||||||
|
try "old".write(to: file, atomically: true, encoding: .utf8)
|
||||||
|
FakeDav.put("/Docs/b.txt", "new")
|
||||||
|
let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
|
||||||
|
XCTAssertTrue(replaced)
|
||||||
|
XCTAssertEqual(local("Docs/b.txt"), "new")
|
||||||
|
FakeDav.failAll = true
|
||||||
|
let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
|
||||||
|
XCTAssertFalse(failed)
|
||||||
|
XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovingTheWholeRootForgetsEveryFile() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
store.removeLocalSync(accountId: "acct", path: "/")
|
||||||
|
XCTAssertTrue(state().isEmpty, "'/' covers everything recorded")
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPruningNeverRemovesAFolderThatHoldsSomething() throws {
|
||||||
|
let root = mirror
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true)
|
||||||
|
try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"])
|
||||||
|
XCTAssertEqual(local("Docs/stale/note.txt"), "mine")
|
||||||
|
XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status, coordinator, config store, mutex
|
||||||
|
|
||||||
|
func testConflictsAreKeptPerAccount() {
|
||||||
|
let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x")
|
||||||
|
let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y")
|
||||||
|
bus.addConflicts(accountId: "A", [a])
|
||||||
|
bus.addConflicts(accountId: "B", [b])
|
||||||
|
XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept")
|
||||||
|
|
||||||
|
let coordinator = SyncCoordinator(
|
||||||
|
store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient())
|
||||||
|
let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]]
|
||||||
|
XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts")
|
||||||
|
|
||||||
|
bus.removeConflict(accountId: "A", fileId: "7")
|
||||||
|
XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() {
|
||||||
|
let service = "test-\(UUID().uuidString)"
|
||||||
|
let configs = SyncConfigStore(service: service)
|
||||||
|
addTeardownBlock { configs.removeAll() }
|
||||||
|
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient())
|
||||||
|
var withBackground = config
|
||||||
|
withBackground.intervalMinutes = 30
|
||||||
|
|
||||||
|
coordinator.reschedule(withBackground)
|
||||||
|
XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30)
|
||||||
|
|
||||||
|
coordinator.cancel(accountId: "acct", forget: false)
|
||||||
|
XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials")
|
||||||
|
XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled")
|
||||||
|
|
||||||
|
coordinator.cancel(accountId: "acct", forget: true)
|
||||||
|
XCTAssertNil(configs.config(for: "acct"), "signing out forgets them")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() {
|
||||||
|
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
|
||||||
|
addTeardownBlock { configs.removeAll() }
|
||||||
|
let second = SyncAccountConfig(
|
||||||
|
accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y",
|
||||||
|
folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true)
|
||||||
|
|
||||||
|
XCTAssertTrue(configs.upsert(config))
|
||||||
|
XCTAssertTrue(configs.upsert(second))
|
||||||
|
XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"])
|
||||||
|
|
||||||
|
var changed = config
|
||||||
|
changed.folders = ["/Docs", "/More"]
|
||||||
|
XCTAssertTrue(configs.upsert(changed))
|
||||||
|
XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"])
|
||||||
|
XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others")
|
||||||
|
|
||||||
|
XCTAssertTrue(configs.remove(accountId: "acct"))
|
||||||
|
XCTAssertEqual(configs.all().map(\.accountId), ["other"])
|
||||||
|
XCTAssertTrue(configs.remove(accountId: "never-existed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws {
|
||||||
|
let mutex = AsyncMutex()
|
||||||
|
let holderStarted = expectation(description: "holder has the lock")
|
||||||
|
let holder = Task {
|
||||||
|
try await mutex.withLock {
|
||||||
|
holderStarted.fulfill()
|
||||||
|
try await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fulfillment(of: [holderStarted], timeout: 5)
|
||||||
|
|
||||||
|
let waiter = Task { try await mutex.withLock { "ran" } }
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
let cancelledAt = Date()
|
||||||
|
waiter.cancel()
|
||||||
|
do {
|
||||||
|
_ = try await waiter.value
|
||||||
|
XCTFail("a cancelled waiter must not run")
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(error is CancellationError)
|
||||||
|
XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await holder.value
|
||||||
|
let next = try await mutex.withLock { "next" }
|
||||||
|
XCTAssertEqual(next, "next", "the lock is free for the next one")
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Counter: @unchecked Sendable {
|
||||||
|
var running = 0
|
||||||
|
var maxRunning = 0
|
||||||
|
var finished = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAMutexNeverRunsTwoBodiesAtOnce() async throws {
|
||||||
|
let mutex = AsyncMutex()
|
||||||
|
let counter = Counter()
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for _ in 0..<6 {
|
||||||
|
group.addTask {
|
||||||
|
_ = try? await mutex.withLock {
|
||||||
|
counter.running += 1
|
||||||
|
counter.maxRunning = max(counter.maxRunning, counter.running)
|
||||||
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
counter.running -= 1
|
||||||
|
counter.finished += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(counter.finished, 6)
|
||||||
|
XCTAssertEqual(counter.maxRunning, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnceGateCompletesOnlyOnce() {
|
||||||
|
var calls: [Bool] = []
|
||||||
|
let gate = OnceGate { calls.append($0) }
|
||||||
|
gate.finish(false)
|
||||||
|
gate.finish(true)
|
||||||
|
XCTAssertEqual(calls, [false])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,12 +105,17 @@ class SyncService {
|
|||||||
)
|
)
|
||||||
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
||||||
final password = await store.readPassword(account.id);
|
final password = await store.readPassword(account.id);
|
||||||
if (password == null ||
|
if (password == null || config.paths.isEmpty) {
|
||||||
config.paths.isEmpty ||
|
|
||||||
config.intervalMinutes == null) {
|
|
||||||
await cancelAccount(account.id);
|
await cancelAccount(account.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (config.intervalMinutes == null) {
|
||||||
|
// Background sync is off, but this account still has paths to sync
|
||||||
|
// by hand: keep its credentials (native uses them for "Sync now"
|
||||||
|
// and conflict notification actions).
|
||||||
|
await cancelAccount(account.id, forget: false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await invokeIfAvailable(_channel, 'reschedule', {
|
await invokeIfAvailable(_channel, 'reschedule', {
|
||||||
'accountId': account.id,
|
'accountId': account.id,
|
||||||
'serverUrl': account.serverUrl,
|
'serverUrl': account.serverUrl,
|
||||||
@@ -132,8 +137,17 @@ class SyncService {
|
|||||||
|
|
||||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||||
/// nothing left to sync).
|
/// nothing left to sync).
|
||||||
static Future<void> cancelAccount(String accountId) async {
|
///
|
||||||
await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId});
|
/// [forget] (the default) is for an account that was signed out or removed:
|
||||||
|
/// native also drops its stored credentials. Pass false when only the
|
||||||
|
/// background job is being turned off - the account's paths are still
|
||||||
|
/// synced by hand ("Sync now"), and a conflict notification's actions
|
||||||
|
/// still need its credentials.
|
||||||
|
static Future<void> cancelAccount(String accountId, {bool forget = true}) async {
|
||||||
|
await invokeIfAvailable(_channel, 'cancel', {
|
||||||
|
'accountId': accountId,
|
||||||
|
'forget': forget,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
|
|||||||
Reference in New Issue
Block a user