Commit f3b4f603 authored by Kourser's avatar Kourser
Browse files

Subscription sync: Nextcloud (GPodder Sync) + gpodder.net (SyncKit)



- SyncKit: PodcastSyncProvider protocol with Nextcloud and gpodder.net
  implementations (HTTP Basic auth, injectable client). Pull/push subscription
  add/remove changes with server timestamps.
- App: SyncSettings (UserDefaults + password in Keychain), SyncView settings
  screen, "Synchronisation" sidebar entry, AppModel.syncNow() (pull → apply →
  push local subscriptions).
- Scope: subscriptions only. Played/position sync is deferred until local
  play-state persistence exists.

38 package tests green; iOS + macOS build; simulators launch clean.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 6ff33da0
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@
		ABCDEF0123456789ABCD0016 /* StorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0013 /* StorageKit */; };
		ABCDEF0123456789ABCD0018 /* PlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0017 /* PlaybackKit */; };
		ABCDEF0123456789ABCD001A /* DiscoveryKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0019 /* DiscoveryKit */; };
		ABCDEF0123456789ABCD001C /* SyncKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD001B /* SyncKit */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
@@ -36,6 +37,7 @@
				ABCDEF0123456789ABCD0016 /* StorageKit in Frameworks */,
				ABCDEF0123456789ABCD0018 /* PlaybackKit in Frameworks */,
				ABCDEF0123456789ABCD001A /* DiscoveryKit in Frameworks */,
				ABCDEF0123456789ABCD001C /* SyncKit in Frameworks */,
			);
			runOnlyForDeploymentPostprocessing = 0;
		};
@@ -83,6 +85,7 @@
				ABCDEF0123456789ABCD0013 /* StorageKit */,
				ABCDEF0123456789ABCD0017 /* PlaybackKit */,
				ABCDEF0123456789ABCD0019 /* DiscoveryKit */,
				ABCDEF0123456789ABCD001B /* SyncKit */,
			);
			productName = 2ndPod;
			productReference = ABCDEF0123456789ABCD0004 /* 2ndPod.app */;
@@ -347,6 +350,10 @@
			isa = XCSwiftPackageProductDependency;
			productName = DiscoveryKit;
		};
		ABCDEF0123456789ABCD001B /* SyncKit */ = {
			isa = XCSwiftPackageProductDependency;
			productName = SyncKit;
		};
/* End XCSwiftPackageProductDependency section */
	};
	rootObject = ABCDEF0123456789ABCD0001 /* Project object */;
+38 −0
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import FeedKit
import StorageKit
import PlaybackKit
import DiscoveryKit
import SyncKit

/// App-wide state: subscriptions, the play queue, and the actions that mutate
/// them. Orchestrates playback (resolving local downloads) and queue advance.
@@ -15,8 +16,11 @@ final class AppModel {
    private(set) var queue: [Episode] = []
    var errorMessage: String?
    var isAdding = false
    var isSyncing = false
    var syncMessage: String?

    @ObservationIgnored let downloads: DownloadController
    @ObservationIgnored let syncSettings = SyncSettings()
    @ObservationIgnored private let store: LibraryStore
    @ObservationIgnored private let fetcher = FeedFetcher()
    @ObservationIgnored private var playback: PlaybackController?
@@ -167,6 +171,40 @@ final class AppModel {
        await loadQueue()
    }

    // MARK: Sync (subscriptions)

    /// Pulls remote subscription changes, applies them locally, then pushes the
    /// local subscription set. Subscription sync only — played/position sync
    /// requires local play-state persistence (not yet implemented).
    func syncNow() async {
        guard let credentials = syncSettings.credentials() else {
            syncMessage = "Configurez d'abord la synchronisation."
            return
        }
        isSyncing = true
        defer { isSyncing = false }
        let provider = makeSyncProvider(credentials)
        do {
            let changes = try await provider.subscriptionChanges(since: syncSettings.lastTimestamp)
            for url in changes.add where !isSubscribed(feedURL: url) {
                await subscribe(feedURL: url)
            }
            for url in changes.remove {
                if let podcast = podcasts.first(where: { $0.feedURL == url }) {
                    await unsubscribe(podcast)
                }
            }
            let newTimestamp = try await provider.uploadSubscriptionChanges(
                add: podcasts.map(\.feedURL), remove: []
            )
            syncSettings.lastTimestamp = newTimestamp ?? changes.timestamp ?? syncSettings.lastTimestamp
            syncSettings.save()
            syncMessage = "Synchronisé — \(podcasts.count) abonnement(s)."
        } catch {
            syncMessage = "Échec de la synchronisation : \(error.localizedDescription)"
        }
    }

    private static func normalizedURL(from raw: String) -> URL? {
        var string = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !string.isEmpty else { return nil }
+35 −0
Original line number Diff line number Diff line
import Foundation
import Security

/// Minimal generic-password Keychain wrapper for storing the sync password.
/// Secrets never go to UserDefaults or logs.
enum KeychainStore {
    private static let service = "com.secondpod.SecondPod.sync"

    static func save(_ value: String, account: String) {
        let base: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        SecItemDelete(base as CFDictionary)
        guard !value.isEmpty else { return }
        var attributes = base
        attributes[kSecValueData as String] = Data(value.utf8)
        SecItemAdd(attributes as CFDictionary, nil)
    }

    static func read(account: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
              let data = item as? Data else { return nil }
        return String(decoding: data, as: UTF8.self)
    }
}
+5 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@ struct RootView: View {

    enum Destination: Hashable {
        case queue
        case sync
        case podcast(UUID)
    }

@@ -25,6 +26,8 @@ struct RootView: View {
                Label("File d'attente", systemImage: "list.bullet")
                    .badge(model.queue.count)
                    .tag(Destination.queue)
                Label("Synchronisation", systemImage: "arrow.triangle.2.circlepath")
                    .tag(Destination.sync)

                Section("Abonnements") {
                    ForEach(model.podcasts) { podcast in
@@ -99,6 +102,8 @@ struct RootView: View {
        switch selection {
        case .queue:
            QueueContentView()
        case .sync:
            SyncView()
        case .podcast(let id):
            if let podcast = model.podcasts.first(where: { $0.id == id }) {
                PodcastDetailView(podcast: podcast)
+1 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ struct SecondPodApp: App {
                .environment(model)
                .environment(playback)
                .environment(model.downloads)
                .environment(model.syncSettings)
                .task {
                    guard !didConfigure else { return }
                    didConfigure = true
Loading