From 7417a9660fa29785026736c185a05666b435740c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 21:22:45 +0000 Subject: [PATCH] Update what's kept on the phone in the background A background processing task syncs the rules for the last Clementine that synced, at its last address, no sooner than 6 hours after the app last went to the background or the last run. iOS starts it only while the phone is on power, and it runs only on a network Download over allows, never in Low Data Mode. It reconnects the app's own session without asking for every playlist's songs, checks that the Clementine answering is the one the rules are for, syncs through Syncer, and disconnects. When the app is already connected, its connection syncs instead. When iOS ends the task, Syncer.stop cancels the sync, which keeps what it has, so the next run carries on. A setting under Download over, on by default, turns it off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R2bAq6kZW6eyymkA3zsd4p --- App/AppModel.swift | 66 +++++++ App/ClementineRemoteApp.swift | 1 + App/Info.plist | 5 + App/Resources/Localizable.xcstrings | 6 + App/Settings/SettingsView.swift | 11 ++ App/Support/NetworkMonitor.swift | 9 + .../ClementineKit/Session/RemoteSession.swift | 18 +- .../ClementineKit/Settings/Settings.swift | 4 + .../ClementineKit/Sync/BackgroundSync.swift | 100 +++++++++++ .../Sources/ClementineKit/Sync/Syncer.swift | 16 +- .../BackgroundSyncTests.swift | 168 ++++++++++++++++++ project.yml | 6 +- 12 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift create mode 100644 Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift diff --git a/App/AppModel.swift b/App/AppModel.swift index 43914cb..4665ee6 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -1,3 +1,4 @@ +import BackgroundTasks import ClementineKit import SwiftUI @@ -299,6 +300,7 @@ final class AppModel { if !session.isPlayingHere { session.suspend() } + scheduleBackgroundSync() case .active: session.resume() default: @@ -330,6 +332,70 @@ final class AppModel { } } +// MARK: Background sync + +extension AppModel { + /// The background task that updates what's kept on the phone: one of the + /// BGTaskSchedulerPermittedIdentifiers in Info.plist. + nonisolated static let backgroundSyncID = (Bundle.main.bundleIdentifier ?? "org.clementine-player.remote") + ".sync" + + /// Lets iOS start background syncs. iOS needs this before the app finishes launching. + func registerBackgroundSync() { + guard TestingFeatures.enabled else { return } + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.backgroundSyncID, using: .main) { [weak self] task in + // On the main queue, as asked, so only the main actor uses it. + nonisolated(unsafe) let task = task + MainActor.assumeIsolated { + if let self { + self.runBackgroundSync(task) + } else { + task.setTaskCompleted(success: false) + } + } + } + } + + /// Asks iOS to start a background sync, while the phone is on power, no sooner than + /// [BackgroundSync.interval] from now; or not to, with Update kept music in the background off. + func scheduleBackgroundSync() { + guard TestingFeatures.enabled else { return } + guard settings.backgroundSync else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.backgroundSyncID) + return + } + let request = BGProcessingTaskRequest(identifier: Self.backgroundSyncID) + request.requiresExternalPower = true + request.requiresNetworkConnectivity = true + request.earliestBeginDate = Date(timeIntervalSinceNow: BackgroundSync.interval) + try? BGTaskScheduler.shared.submit(request) + } + + /// Syncs for [task], over a network Download over allows, and asks for the next one. + private func runBackgroundSync(_ task: BGTask) { + scheduleBackgroundSync() + let run = Task { + guard let path = await NetworkMonitor.currentPath(), + BackgroundSync.allows( + settings.downloadNetwork, expensive: path.expensive, constrained: path.constrained) + else { return } + await BackgroundSync.run(session: session, syncer: syncer, settings: settings) { + UIApplication.shared.applicationState == .background + } + } + // iOS ends the task: what's downloaded so far stays, and the next sync carries on. + task.expirationHandler = { [syncer] in + Task { @MainActor in + syncer.stop() + run.cancel() + } + } + Task { + await run.value + task.setTaskCompleted(success: true) + } + } +} + /// Why the app couldn't connect, or stopped being connected. enum ConnectProblem: Equatable, Identifiable { /// Clementine wants an auth code, or a different one. diff --git a/App/ClementineRemoteApp.swift b/App/ClementineRemoteApp.swift index 618712d..8412e17 100644 --- a/App/ClementineRemoteApp.swift +++ b/App/ClementineRemoteApp.swift @@ -9,6 +9,7 @@ struct ClementineRemoteApp: App { init() { let model = AppModel() AppModel.shared = model + model.registerBackgroundSync() _model = State(initialValue: model) } diff --git a/App/Info.plist b/App/Info.plist index 1d9c4f6..ce929ba 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -4,6 +4,10 @@ AppGroup $(APP_GROUP) + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).sync + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -44,6 +48,7 @@ UIBackgroundModes audio + processing UIFileSharingEnabled diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 642bbf5..813038a 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -26170,6 +26170,9 @@ "Update it" : { "comment" : "Heading in the Keep on this phone sheet, over how often what's kept is updated from Clementine." }, + "Update kept music in the background" : { + "comment" : "A switch, under Download over: lets the phone update what's kept on it from Clementine by itself." + }, "Update library" : { "localizations" : { "el" : { @@ -26742,6 +26745,9 @@ } } }, + "While the phone is charging, on a network Download over allows, music set to Every time I connect is updated from the last Clementine you connected to, if it's on." : { + "comment" : "Under the switch above. \"Every time I connect\" is the choice in the Keep on this phone sheet." + }, "Wi-Fi and mobile data, not in Low Data Mode" : { "comment" : "A choice under Download over: Wi-Fi, and mobile data unless Low Data Mode is on.", "localizations" : { diff --git a/App/Settings/SettingsView.swift b/App/Settings/SettingsView.swift index cdb2dcd..43e8081 100644 --- a/App/Settings/SettingsView.swift +++ b/App/Settings/SettingsView.swift @@ -11,6 +11,7 @@ struct SettingsView: View { @AppStorage(SettingKey.librarySorting) private var sorting = LibrarySorting.ascending.rawValue @AppStorage(SettingKey.wifiOnly) private var wifiOnly = false @AppStorage(SettingKey.downloadNetwork) private var downloadNetwork: String? + @AppStorage(SettingKey.backgroundSync) private var backgroundSync = true @AppStorage(SettingKey.replaceExisting) private var replaceExisting = false @AppStorage(SettingKey.playlistFolder) private var playlistFolder = false @AppStorage(SettingKey.artistFolder) private var artistFolder = true @@ -77,6 +78,16 @@ struct SettingsView: View { "Download over", comment: "A setting: which networks songs download over, for downloads and for what's kept on this phone. Its choices follow.") } + if TestingFeatures.enabled { + Toggle(isOn: $backgroundSync) { + Text( + "Update kept music in the background", + comment: "A switch, under Download over: lets the phone update what's kept on it from Clementine by itself.") + Text( + "While the phone is charging, on a network Download over allows, music set to Every time I connect is updated from the last Clementine you connected to, if it's on.", + comment: "Under the switch above. \"Every time I connect\" is the choice in the Keep on this phone sheet.") + } + } Toggle(isOn: $replaceExisting) { Text("Replace existing files") Text("Downloads songs again when they differ from those on this phone.") diff --git a/App/Support/NetworkMonitor.swift b/App/Support/NetworkMonitor.swift index ebba5a3..c0dee44 100644 --- a/App/Support/NetworkMonitor.swift +++ b/App/Support/NetworkMonitor.swift @@ -44,6 +44,15 @@ final class NetworkMonitor { network.allows(expensive: isExpensive, constrained: isConstrained) } + /// The network the phone is on now, as iOS first says: whether it's [expensive] and + /// [constrained]. Nil if iOS doesn't say, as when the task is cancelled. + nonisolated static func currentPath() async -> (expensive: Bool, constrained: Bool)? { + for await path in NWPathMonitor() { + return (expensive: path.isExpensive, constrained: path.isConstrained) + } + return nil + } + /// What's wrong with the network for reaching Clementine, if anything. var problem: Problem? { if !isOnWiFi { diff --git a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift index df4f8ce..161e998 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift @@ -127,9 +127,11 @@ public final class RemoteSession { // MARK: Connecting /// Connects to Clementine at [endpoint], called [name] (its address if nil). With [renderer], - /// this device offers itself as somewhere Clementine can play. + /// this device offers itself as somewhere Clementine can play. Without [sendPlaylistSongs], as + /// for a background sync, Clementine sends the playlists but not their songs. public func connect( - to endpoint: Endpoint, name: String? = nil, authCode: Int32, renderer: RendererCapabilities? = nil + to endpoint: Endpoint, name: String? = nil, authCode: Int32, renderer: RendererCapabilities? = nil, + sendPlaylistSongs: Bool = true ) { drop() reset() @@ -141,7 +143,7 @@ public final class RemoteSession { connectedSince = nil trafficAtConnect = Traffic.byteCounts status = .connecting - open(sendPlaylistSongs: true) + open(sendPlaylistSongs: sendPlaylistSongs, renderer: renderer) } /// Disconnects, and forgets Clementine's state. @@ -161,14 +163,16 @@ public final class RemoteSession { status = .suspended } - /// Reconnects after [suspend], then refreshes the playlists. - public func resume() { + /// Reconnects after [suspend], then refreshes the playlists. Without [offeringRenderer], as + /// for a background sync, this connection doesn't offer the phone for Clementine to play on, + /// so nothing can start playing on it unasked; the next connection offers it again. + public func resume(offeringRenderer: Bool = true) { guard status == .suspended, endpoint != nil else { return } status = .reconnecting requestedPlaylists = [] playlistsLoading = nil refreshPlaylistsWhenReady = true - open(sendPlaylistSongs: false) + open(sendPlaylistSongs: false, renderer: offeringRenderer ? renderer : nil) } /// Everything the app has sent and received since connecting: to Clementine, and audio streamed @@ -192,7 +196,7 @@ public final class RemoteSession { observers[id] = nil } - private func open(sendPlaylistSongs: Bool) { + private func open(sendPlaylistSongs: Bool, renderer: RendererCapabilities?) { guard let endpoint else { return } let connection = ClementineConnection(endpoint: endpoint, authCode: authCode, renderer: renderer) self.connection = connection diff --git a/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift b/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift index de39004..16c2fb1 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift @@ -21,6 +21,8 @@ public enum SettingKey { public static let playlistFolder = "pref_dl_pl_save_own_dir" public static let artistFolder = "pref_dl_artist_dir" public static let albumFolder = "pref_dl_album_dir" + /// Whether what's kept on the phone is updated in the background (BackgroundSync). + public static let backgroundSync = "pref_background_sync" // Connection public static let autoConnect = "pref_autoconnect" public static let port = "pref_port" @@ -52,6 +54,7 @@ public struct Settings: Sendable { SettingKey.playlistFolder: false, SettingKey.artistFolder: true, SettingKey.albumFolder: true, + SettingKey.backgroundSync: true, SettingKey.autoConnect: true, SettingKey.port: Int(RemoteProtocol.defaultPort), SettingKey.keepScreenOn: false, @@ -90,6 +93,7 @@ public struct Settings: Sendable { public var playlistFolder: Bool { store.bool(forKey: SettingKey.playlistFolder) } public var artistFolder: Bool { store.bool(forKey: SettingKey.artistFolder) } public var albumFolder: Bool { store.bool(forKey: SettingKey.albumFolder) } + public var backgroundSync: Bool { store.bool(forKey: SettingKey.backgroundSync) } public var autoConnect: Bool { store.bool(forKey: SettingKey.autoConnect) } public var port: UInt16 { UInt16(exactly: store.integer(forKey: SettingKey.port)) ?? RemoteProtocol.defaultPort diff --git a/Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift b/Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift new file mode 100644 index 0000000..3c20eb6 --- /dev/null +++ b/Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Updates what's kept on the phone in the background, with Update kept music in the background +/// on. The app runs [run] from a background processing task about every 6 hours, which iOS starts +/// while the phone is on power, usually overnight. A run connects to the last Clementine that +/// synced, at its last address, syncs its rules and disconnects. +@MainActor +public enum BackgroundSync { + /// How soon after one run the next may start. + public static let interval: TimeInterval = 6 * 60 * 60 + /// How long a run waits for Clementine to say what it is. + static let connectTimeout: Duration = .seconds(15) + + /// Whether a run may use a network that's [expensive], such as mobile data, and + /// [constrained], in Low Data Mode: what [network], chosen under Download over, allows, but + /// never in Low Data Mode, which asks apps to hold back work that can wait. + public nonisolated static func allows(_ network: DownloadNetwork, expensive: Bool, constrained: Bool) -> Bool { + !constrained && network.allows(expensive: expensive, constrained: constrained) + } + + /// Whether any of [rules] would sync: those updated each time, and those yet to get their + /// songs. + static func hasRulesToSync(_ rules: [SyncRule]) -> Bool { + rules.contains { $0.refreshPolicy != .never || $0.lastSynced == nil } + } + + /// Syncs in the background with [session], the app's, through [syncer], and returns whether it + /// synced. If the app is connected, its connection syncs. Otherwise it connects to the last + /// Clementine that synced, or reconnects a session suspended in the background, syncs if the + /// Clementine that answers is the one the rules are for, and disconnects again, unless + /// [inBackground] says the user has opened the app meanwhile. Cancelling it, as when iOS ends + /// the run, gives up waiting; [Syncer.stop] stops the sync itself. + @discardableResult + public static func run( + session: RemoteSession, syncer: Syncer, settings: Settings, inBackground: () -> Bool = { true } + ) async -> Bool { + guard TestingFeatures.enabled, settings.backgroundSync else { return false } + switch session.status { + case .connected: + syncer.syncNow() + await syncer.finish() + return true + case .connecting, .downloadingData, .reconnecting: + // Its connection syncs once Clementine says what it is. + return false + case .disconnected, .suspended: + break + } + + let serverID = settings.syncServerID + guard !serverID.isEmpty, !settings.lastHost.isEmpty, let store = syncer.store, + let rules = try? await store.rules(serverID: serverID), hasRulesToSync(rules) + else { return false } + + syncer.onlyServerID = serverID + defer { syncer.onlyServerID = nil } + // The syncer starts syncing when Clementine says what it is, if it's the one. A suspended + // session still has what the last Clementine said, so this waits for it to say it again. + let heard = Heard() + let observer = session.addObserver { message in + if message.type == .info { + heard.info = true + } + } + defer { session.removeObserver(observer) } + let wasSuspended = session.status == .suspended + if wasSuspended { + // Not offering the phone to play on: a Clementine could start playing on it unasked. + session.resume(offeringRenderer: false) + } else { + session.connect( + to: Endpoint(host: settings.lastHost, port: settings.port), authCode: settings.lastAuthCode, + sendPlaylistSongs: false) + } + + let deadline = ContinuousClock.now + connectTimeout + while !heard.info, session.status != .disconnected, ContinuousClock.now < deadline, !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(100)) + } + let synced = heard.info && session.syncing.available && session.syncing.serverID == serverID + if synced { + await syncer.finish() + } + + if inBackground() { + if wasSuspended { + session.suspend() + } else { + session.disconnect() + } + } + return synced + } +} + +/// Whether Clementine has said what it is. +@MainActor +private final class Heard { + var info = false +} diff --git a/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift b/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift index 8b62676..504abab 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift @@ -39,7 +39,8 @@ public struct KeptRule: Sendable, Equatable, Identifiable { /// Syncs the rules for the connected Clementine while the app is connected to it: each time it /// connects, and when asked ([syncNow]), such as after a rule is made ([keep]) or removed -/// ([remove]), over the networks Download over allows. Background sync comes later. +/// ([remove]), over the networks Download over allows. [BackgroundSync] connects in the +/// background for it. @MainActor @Observable public final class Syncer { @@ -68,6 +69,9 @@ public final class Syncer { @ObservationIgnored private let timeout: Duration /// Whether the phone's network is one that Download over allows. @ObservationIgnored public var networkAllows: @MainActor (DownloadNetwork) -> Bool = { _ in true } + /// While set, as during a background sync, only this Clementine syncs: another that connects + /// is left alone, and doesn't become the one the rules shown are for. + @ObservationIgnored public var onlyServerID: String? @ObservationIgnored private var task: Task? @ObservationIgnored private var again = false @@ -89,6 +93,7 @@ public final class Syncer { manifests.handle(message) // Clementine says what it can do each time the app connects. guard message.type == .info else { return } + if let onlyServerID, session.syncing.serverID != onlyServerID { return } if session.syncing.supported { settings.syncServerID = session.syncing.serverID refresh() @@ -175,6 +180,13 @@ public final class Syncer { await task?.value } + /// Stops the sync that's running, as when iOS ends a background sync. The songs downloaded + /// so far stay, and the next sync carries on. + public func stop() { + again = false + task?.cancel() + } + private func runOnce() async { let syncing = session.syncing guard syncing.available else { return } @@ -197,6 +209,8 @@ public final class Syncer { timeout: timeout) do { status.results = try await engine.sync(connection).rules + } catch is CancellationError { + // Stopped: what finished stays, and the next sync carries on. } catch { // The store failing, say: tried again next time. status.unavailable = true diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift new file mode 100644 index 0000000..201e805 --- /dev/null +++ b/Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +@testable import ClementineKit + +/// Updating what's kept on the phone in the background. +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct BackgroundSyncTests { + /// Settings for a phone that last synced with SyncerTests.server, at [endpoint]. + static func settings(_ endpoint: Endpoint) -> Settings { + let settings = Settings(suiteName: "BackgroundSyncTests-\(UUID().uuidString)") + settings.syncServerID = SyncerTests.server + settings.lastHost = endpoint.host + settings.store.set(Int(endpoint.port), forKey: SettingKey.port) + return settings + } + + /// Clementine that says it's [serverID], and answers each manifest request with no songs. + static func clementine(serverID: String = SyncerTests.server) async throws -> FakeClementine { + let clementine = try await FakeClementine() + clementine.respond { message, client in + switch message.type { + case .connect: + for reply in [SyncerTests.syncingInfo(serverID: serverID)] + FakeClementine.firstData.dropFirst() { + try? await client.send(reply) + } + case .requestSyncManifest: + try? await client.send(SyncerTests.manifest(message.requestSyncManifest.sourceID)) + default: + break + } + } + return clementine + } + + static func playlistRule(_ policy: RefreshPolicy = .onConnect) -> SyncRule { + SyncRule(serverID: SyncerTests.server, sourceID: "playlist/1", title: "Playlist 1", kind: .playlist, refreshPolicy: policy) + } + + @Test func neverRunsInLowDataMode() { + #expect(BackgroundSync.allows(.wifiOnly, expensive: false, constrained: false)) + #expect(!BackgroundSync.allows(.wifiOnly, expensive: true, constrained: false)) + #expect(BackgroundSync.allows(.any, expensive: true, constrained: false)) + #expect(!BackgroundSync.allows(.any, expensive: false, constrained: true)) + #expect(!BackgroundSync.allows(.wifiOnly, expensive: false, constrained: true)) + } + + @Test func syncsRulesUpdatedEachTimeAndThoseYetToGetTheirSongs() { + var album = Self.playlistRule(.never) + #expect(BackgroundSync.hasRulesToSync([album])) + album.lastSynced = .now + #expect(!BackgroundSync.hasRulesToSync([album])) + #expect(BackgroundSync.hasRulesToSync([album, Self.playlistRule()])) + #expect(!BackgroundSync.hasRulesToSync([])) + } + + @Test func connectsSyncsAndDisconnects() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + let rule = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(await BackgroundSync.run(session: session, syncer: syncer, settings: settings)) + #expect(syncer.status.results[rule] == .synced(songs: 0, downloaded: 0, skipped: 0)) + #expect(session.status == .disconnected) + #expect(syncer.onlyServerID == nil) + // Only what syncing needs: not every playlist's songs. + let connect = try #require(clementine.received.first { $0.type == .connect }) + #expect(!connect.requestConnect.sendPlaylistSongs) + } + + @Test func leavesAnotherClementineAtTheSameAddressAlone() async throws { + let clementine = try await Self.clementine(serverID: "another") + defer { clementine.stop() } + let store = try SyncStore(url: nil) + _ = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + #expect(!clementine.received.contains { $0.type == .requestSyncManifest }) + #expect(settings.syncServerID == SyncerTests.server) + #expect(session.status == .disconnected) + } + + @Test func givesUpWhenClementineIsntThere() async throws { + let clementine = try await FakeClementine() + let endpoint = clementine.endpoint + clementine.stop() + let store = try SyncStore(url: nil) + _ = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + #expect(session.status == .disconnected) + } + + @Test func doesntConnectWithNothingToSyncOrWithTheSettingOff() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + var album = Self.playlistRule(.never) + album.lastSynced = .now + _ = try await store.addRule(album) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + var playlist = Self.playlistRule() + playlist.sourceID = "playlist/2" + _ = try await store.addRule(playlist) + settings.store.set(false, forKey: SettingKey.backgroundSync) + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + #expect(session.status == .disconnected) + #expect(clementine.clientCount == 0) + } + + @Test func syncsOverTheAppsConnection() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + let rule = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + session.connect(to: clementine.endpoint, authCode: 0) + try await eventually { session.status == .connected } + await syncer.finish() + + #expect(await BackgroundSync.run(session: session, syncer: syncer, settings: settings)) + #expect(syncer.status.results[rule] != nil) + // The app's connection stays. + #expect(session.status == .connected) + session.disconnect() + } + + @Test func reconnectsASuspendedSessionWithoutOfferingThePhoneToPlayOn() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + let rule = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + session.connect( + to: clementine.endpoint, authCode: 0, renderer: Renderer.capabilities(id: "phone", name: "iPhone")) + try await eventually { session.status == .connected } + await syncer.finish() + session.suspend() + + #expect(await BackgroundSync.run(session: session, syncer: syncer, settings: settings)) + #expect(syncer.status.results[rule] != nil) + #expect(session.status == .suspended) + let connects = clementine.received.filter { $0.type == .connect } + #expect(connects.count == 2) + #expect(connects.first?.requestConnect.hasRenderer == true) + #expect(connects.last?.requestConnect.hasRenderer == false) + // The app offers it again when it next resumes. + #expect(session.renderer != nil) + } +} diff --git a/project.yml b/project.yml index ddc293b..12d4262 100644 --- a/project.yml +++ b/project.yml @@ -72,9 +72,13 @@ targets: LSSupportsOpeningDocumentsInPlace: true ITSAppUsesNonExemptEncryption: false # Remote streaming: Clementine's music keeps playing in the background, fetched over plain - # HTTP from Clementine's media server on the local network. + # HTTP from Clementine's media server on the local network. Processing: what's kept on the + # phone is updated from Clementine in a background task (BackgroundSync). UIBackgroundModes: - audio + - processing + BGTaskSchedulerPermittedIdentifiers: + - $(PRODUCT_BUNDLE_IDENTIFIER).sync NSAppTransportSecurity: NSAllowsLocalNetworking: true NSAllowsArbitraryLoadsForMedia: true