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()