diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e6f102..8b930a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: ios-build: name: iOS Build needs: lint - runs-on: macos-26 + runs-on: xcode-27 steps: - name: Checkout diff --git a/BitDream/Info.plist b/BitDream/Info.plist index bd27541..6c36340 100644 --- a/BitDream/Info.plist +++ b/BitDream/Info.plist @@ -76,6 +76,10 @@ + UIBackgroundModes + + fetch + BGTaskSchedulerPermittedIdentifiers $(PRODUCT_BUNDLE_IDENTIFIER).refresh diff --git a/BitDream/Widgets/AppGroup.swift b/BitDream/Widgets/AppGroup.swift index acfeb59..bebf82d 100644 --- a/BitDream/Widgets/AppGroup.swift +++ b/BitDream/Widgets/AppGroup.swift @@ -75,10 +75,7 @@ enum AppGroupJSON { let directory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let tmpURL = directory.appendingPathComponent(UUID().uuidString) - try data.write(to: tmpURL, options: .atomic) - try? FileManager.default.removeItem(at: url) - try FileManager.default.moveItem(at: tmpURL, to: url) + try data.write(to: url, options: .atomic) return true } catch { logger.error("Failed to write App Group file \(url.lastPathComponent): \(error.localizedDescription)") diff --git a/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift b/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift index c6d7669..5645c6e 100644 --- a/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift +++ b/BitDream/Widgets/BackgroundRefresh/iOSBackgroundRefresh.swift @@ -28,39 +28,58 @@ private final class AppRefreshTaskBox: @unchecked Sendable { enum BackgroundRefreshManager { static let taskIdentifier = "\(AppIdentity.bundleIdentifier).refresh" private static let logger = Logger(subsystem: AppIdentity.bundleIdentifier, category: "backgroundRefresh") - /// Default refresh cadence for background app refresh (15 minutes) - /// iOS executes opportunistically; this expresses our desired minimum cadence + /// The earliest requested start is 15 minutes away; iOS decides whether and when to run. private static let defaultRefreshInterval: TimeInterval = 15 * 60 static func register() { - BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in guard let refreshTask = task as? BGAppRefreshTask else { task.setTaskCompleted(success: false) return } handle(task: refreshTask) } + if !registered { + logger.error("BGTaskScheduler registration failed for \(taskIdentifier)") + } } static func schedule(earliestBegin interval: TimeInterval = defaultRefreshInterval) { - // Ensure only one pending refresh request exists for this identifier - BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier) let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) request.earliestBeginDate = Date().addingTimeInterval(interval) - do { try BGTaskScheduler.shared.submit(request) } catch { + // Submitting the same identifier replaces its pending request. + if #available(iOS 27, *) { + BGTaskScheduler.shared.submitTaskRequest(request) { error in + logSubmission(error: error) + } + } else { + do { + try BGTaskScheduler.shared.submit(request) + logSubmission(error: nil) + } catch { + logSubmission(error: error) + } + } + } + + private static func logSubmission(error: Error?) { + if let error { logger.error("BGTaskScheduler submit failed for \(taskIdentifier): \(error.localizedDescription)") + } else { + logger.debug("BGTaskScheduler submitted \(taskIdentifier)") } } private static func handle(task: BGAppRefreshTask) { - schedule() // schedule the next one ASAP to keep cadence + schedule() // Request another opportunity before starting this refresh. let taskBox = AppRefreshTaskBox(task: task) let refreshHandle = WidgetRefreshScheduler.enqueue { success in taskBox.complete(success: success) } - task.expirationHandler = { + task.expirationHandler = { [weak taskBox] in + guard let taskBox else { return } refreshHandle.cancel() taskBox.complete(success: false) } diff --git a/BitDream/Widgets/README.md b/BitDream/Widgets/README.md index 8fb4d70..b74440b 100644 --- a/BitDream/Widgets/README.md +++ b/BitDream/Widgets/README.md @@ -13,13 +13,13 @@ - `ServerEntity` exposes servers to the widget’s configuration UI. - `SessionOverviewIntent` stores the per-widget server selection. - Snapshot IO (App-side) - - Writers live in `BitDream/Widgets/ServerSnapshotIO.swift`. + - Writers live in `BitDream/Widgets/DataWriter.swift` and `AppGroup.swift`. - Files: - `servers.json`: list of available servers for the picker. - `session_.json`: per-server snapshot consumed by the widget. ### Data Flow -1) App (TransmissionStore) refreshes Transmission state on a timer (macOS: always while app runs; iOS: when in foreground or BG task wakes). +1) `TransmissionStore` supplies snapshots from normal app polling. Background schedulers independently use `WidgetRefreshRunner` to fetch saved servers through the host-only refresh catalog. 2) After refresh, the app writes a JSON snapshot to the App Group. 3) The widget provider reads the snapshot at render time and constructs the view. 4) The app nudges WidgetKit via `WidgetCenter.reloadTimelines(ofKind:)` after writing. @@ -28,17 +28,21 @@ - Timeline policy: `.after(now + X minutes)` to align with WidgetKit guidance on budgeting and predictable refreshes. - Reload triggers: - Host app writes a new snapshot (foreground updates). - - iOS Background Tasks periodically wake the app to refresh and write snapshots. + - iOS may launch or resume the app for a scheduled background refresh, which fetches fresh data and writes snapshots. - User edits the widget’s configuration (server selection) → WidgetKit requests a new timeline. -### iOS Background Refresh (by the book) -- Capability: Background Modes → Background fetch (enabled on iOS target). -- Scheduler: `BGTaskScheduler` with identifier `com.crapshack.BitDream.refresh`. -- Flow: - - Register on app launch. - - Schedule on launch and when entering background. - - Handler fetches latest state per saved server, writes snapshots, then calls `WidgetCenter.reloadTimelines`. -- Budget: Respects system-controlled reload budgets; cadence targeted at ~15–30 minutes but ultimately governed by iOS. +### iOS Background Refresh + +- The host app declares `UIBackgroundModes = [fetch]` and `BGTaskSchedulerPermittedIdentifiers = [$(PRODUCT_BUNDLE_IDENTIFIER).refresh]` in `BitDream/Info.plist`. These iOS settings share the direct app's plist with macOS; the macOS App Store target uses its separate plist. The widget extension does not register or execute background tasks. +- `BitDreamApp.init` registers the launch handler synchronously, before app launch completes. Registration failures are logged. +- The app submits a request after startup bootstrap and when its scene enters the background. The launch handler submits the next request before fetching data. +- Submission uses the completion-handler API on iOS 27 and the synchronous API on iOS 26. Submitting the same identifier replaces the pending request; no separate cancellation is needed. Submission results are logged under the `backgroundRefresh` category. +- The requested earliest start is 15 minutes later. This is a lower bound, **not a refresh interval or deadline**. iOS can defer execution for hours or decline it entirely based on settings, usage, and system conditions. WidgetKit separately controls when a requested timeline reload appears. +- A refresh loads saved host descriptors, obtains credentials in the host app, and fetches each server with a 15-second timeout. Network failures preserve that server's previous snapshot; other servers are still attempted. Successful session stats can be saved even if the torrent summary request fails. +- Snapshots replace their previous files atomically. A completed batch requests one timeline reload. Expiration cancels queued or active refresh work and reports failure; the task completion guard prevents duplicate completion. Cancelled work skips subsequent snapshot writes and the batch reload. +- Credentials use Keychain accessibility after first unlock. Test locked-device operation after unlocking once following a reboot. Background App Refresh must be enabled, and the app must have been opened to save its server configuration. Force-quitting the app prevents normal background launches until the user opens it again. + +Apple references: [background task setup](https://developer.apple.com/documentation/uikit/using-background-tasks-to-update-your-app), [BGAppRefreshTask requirements](https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask), [iOS 27 submission API](https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler/submittaskrequest(_:completionhandler:)), and [Apple's clarification that submission supports any thread](https://developer.apple.com/forums/thread/840876). ### macOS Behavior - Uses the app's existing refresh loop to keep snapshots current while the app is running. @@ -65,10 +69,34 @@ - Timeline intervals should be ≥ 5 minutes; WidgetKit may adjust scheduling based on usage. ### Testing -- iOS: Xcode → Debug → Simulate Background Fetch to validate BG task execution and widget updates. -- Verify the App Group container path returns a valid URL in both app and extension. -- Use Widget Previews for layout across supported families (small/medium). -- Deep links: Tap the widget to open BitDream directly to the selected server. + +- The iOS CI job uses GitHub's `xcode-27` runner because the new submission API requires the iOS 27 SDK. macOS jobs keep their existing runner. +- `WidgetRefreshOperationTests` covers snapshot production, timeout behavior, partial network failure, one reload per batch, serialized refreshes, and cancellation of active and queued work. +- Verify App Group access from both the host app and widget extension. Use Widget Previews for supported layouts. + +#### Physical-device background task checks + +Apple's [development task simulation](https://developer.apple.com/documentation/backgroundtasks/starting-and-terminating-tasks-during-development) works on physical devices. Xcode's legacy **Simulate Background Fetch** command does not exercise this `BGTaskScheduler` handler. + +1. Build with Xcode 27 or later and run on an iPhone. Add a reachable server, open the app once, and configure a widget for it. Check `backgroundRefresh` logs for successful submission and verify the pending request identifier is `com.crapshack.BitDream.refresh`. +2. Set a breakpoint after successful submission in `logSubmission(error:)`. Use Apple's debugger-only command, then resume: + + ```text + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.crapshack.BitDream.refresh"] + ``` + +3. Verify that the launch handler runs, requests its successor, writes fresh session JSON in the shared App Group, requests a timeline reload, and completes once. The JSON timestamp is the direct freshness check; WidgetKit may defer the visible reload. +4. With a slow or unreachable server, break after `expirationHandler` is installed and simulate expiration, then resume: + + ```text + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.crapshack.BitDream.refresh"] + ``` + + Confirm cancellation, one unsuccessful completion, and no subsequent snapshot write or batch reload from that cancelled refresh. +5. Test an offline server alongside a reachable one, no saved servers, and Background App Refresh disabled. Verify existing snapshots survive failures and submission errors are logged without crashing. +6. Finally, run without the debugger, background the app normally, and verify a later refresh on a locked device after first unlock. Repeat on iOS 26 and 27 to cover both submission APIs. Natural execution is system-controlled; debugger simulation proves the handler, not an execution cadence. + +The private simulation selectors above belong only in the debugger. Never add them to app code. ### Error Tolerance - If a snapshot is missing or unreadable, the widget presents a configuration prompt rather than failing. @@ -102,16 +130,13 @@ - User-controlled via settings **Phase 3**: Background Refresh Optimization -- iOS: Cancel old BG tasks before scheduling new ones - Reload coalescing: Check `getCurrentConfigurations` before reloading - Network reachability checks before attempting refresh ### Known gaps / next steps - macOS background updates when the app isn't running (Phase 2 of roadmap addresses this) -- iOS BG hygiene: no cancellation of existing BG requests before scheduling new ones - Reload coalescing: `WidgetCenter.reloadTimelines` called after each snapshot write - Timeline relevance: `relevance()` not implemented; affects Smart Stack surfacing - "Last updated" affordance: not shown; consider compact timestamp label - Provider networking: widget extension intentionally performs no network I/O - diff --git a/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift b/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift index 8c092b9..7e9156a 100644 --- a/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift +++ b/BitDreamTests/Widgets/WidgetRefreshOperationTests.swift @@ -155,6 +155,67 @@ final class WidgetRefreshOperationTests: XCTestCase { XCTAssertEqual(recorder.reloadCount, 1) } + func testCancellingRunningRefreshPreventsSnapshotWriteAndReload() async throws { + let scenario = try makeSerializedRefreshScenario() + let completed = expectation(description: "Queue drained after cancellation") + let handle = WidgetRefreshScheduler.enqueue(dependencies: scenario.dependencies) { _ in + XCTFail("Cancelled refresh must not report normal completion") + } + let started = await waitUntil { + await scenario.sender.capturedRequests().count == 2 + } + XCTAssertTrue(started) + + handle.cancel() + await scenario.sender.resume(id: "first-run-stats") + // An empty refresh completes only after the cancelled predecessor has drained. + let emptyDependencies = WidgetRefreshDependencies( + connectionFactory: scenario.dependencies.connectionFactory, + snapshotWriter: scenario.dependencies.snapshotWriter, + loadHosts: { [] }, + sleep: sleepUntilCancelled + ) + WidgetRefreshScheduler.enqueue(dependencies: emptyDependencies) { success in + XCTAssertTrue(success) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 5) + + XCTAssertTrue(handle.isCancelled) + XCTAssertEqual(scenario.recorder.sessionSnapshotCount, 0) + XCTAssertEqual(scenario.recorder.reloadCount, 0) + } + + func testCancellingQueuedRefreshSkipsItsNetworkWork() async throws { + let scenario = try makeSerializedRefreshScenario() + let firstCompleted = expectation(description: "First refresh completed") + let lastCompleted = expectation(description: "Uncancelled successor completed") + WidgetRefreshScheduler.enqueue(dependencies: scenario.dependencies) { success in + XCTAssertTrue(success) + firstCompleted.fulfill() + } + let started = await waitUntil { + await scenario.sender.capturedRequests().count == 2 + } + XCTAssertTrue(started) + + let cancelled = WidgetRefreshScheduler.enqueue(dependencies: scenario.dependencies) { _ in + XCTFail("Cancelled refresh must not report normal completion") + } + cancelled.cancel() + WidgetRefreshScheduler.enqueue(dependencies: scenario.dependencies) { success in + XCTAssertTrue(success) + lastCompleted.fulfill() + } + await scenario.sender.resume(id: "first-run-stats") + await fulfillment(of: [firstCompleted, lastCompleted], timeout: 5) + + let requests = await scenario.sender.capturedRequests() + XCTAssertEqual(requests.count, 4) + XCTAssertEqual(scenario.recorder.sessionSnapshotCount, 2) + XCTAssertEqual(scenario.recorder.reloadCount, 2) + } + func testEnqueuedWidgetRefreshesSerializeNetworkWork() async throws { let scenario = try makeSerializedRefreshScenario() let completionRecorder = WidgetRefreshCompletionRecorder()