Commit ec03d9eb authored by Kourser's avatar Kourser
Browse files

feat: startup update prompt driven by a version file on the docs site

At launch the app fetches appcast.json from the docs site (GitLab Pages)
and, once per version, prompts to update via the App Store when the
published marketing version is newer than the installed one. Failures are
swallowed so the check never disturbs launch.

Co-Authored-By: Claude (RCA)
parent eb765a8c
Loading
Loading
Loading
Loading
+16 −0
Original line number Diff line number Diff line
@@ -9,6 +9,8 @@ struct RootView: View {
    @Environment(AppModel.self) private var model
    @Environment(PlaybackController.self) private var playback
    @Environment(CommandCenter.self) private var commands
    @Environment(UpdateChecker.self) private var updateChecker
    @Environment(\.openURL) private var openURL
    @Environment(\.scenePhase) private var scenePhase
    @State private var selection: Destination?
    @State private var showingSearch = false
@@ -151,6 +153,15 @@ struct RootView: View {
        } message: { podcast in
            Text(\(podcast.title) » et tous ses épisodes seront retirés de ta bibliothèque, avec ton historique de lecture. Tu pourras te réabonner plus tard.")
        }
        .alert("Mise à jour disponible", isPresented: updateAlertBinding, presenting: updateChecker.available) { update in
            Button("Plus tard", role: .cancel) { updateChecker.available = nil }
            Button("Mettre à jour") {
                openURL(update.storeURL)
                updateChecker.available = nil
            }
        } message: { update in
            Text("La version \(update.version) est disponible sur l'App Store.")
        }
        .task { await applyDemoScreen() }
        .onChange(of: scenePhase) { _, phase in
            if phase == .background {
@@ -295,6 +306,11 @@ struct RootView: View {
                set: { if !$0 { podcastToUnsubscribe = nil } })
    }

    private var updateAlertBinding: Binding<Bool> {
        Binding(get: { updateChecker.available != nil },
                set: { if !$0 { updateChecker.available = nil } })
    }

    // MARK: Detail

    @ViewBuilder private var detail: some View {
+3 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ struct SkingomzApp: App {
    @State private var playbackSettings = PlaybackSettings()
    @State private var languageSettings = LanguageSettings()
    @State private var commandCenter = CommandCenter()
    @State private var updateChecker = UpdateChecker()
    @State private var nowPlaying: NowPlayingCoordinator?
    @State private var didConfigure = false

@@ -28,6 +29,7 @@ struct SkingomzApp: App {
                .environment(playbackSettings)
                .environment(languageSettings)
                .environment(commandCenter)
                .environment(updateChecker)
                .environment(model.downloads)
                .environment(model.syncSettings)
                .environment(model.downloadSettings)
@@ -50,6 +52,7 @@ struct SkingomzApp: App {
                    )
                    #endif
                    await model.start()
                    await updateChecker.check()
                }
        }
        #if os(macOS)
+84 −0
Original line number Diff line number Diff line
import Foundation
import Observation

/// One entry from the published appcast: the latest released marketing
/// version and, per platform, where to get it on the App Store.
private struct Appcast: Decodable {
    struct StoreURLs: Decodable {
        var ios: URL?
        var macos: URL?
    }
    var version: String
    var urls: StoreURLs
}

/// A newer release the user hasn't been prompted about yet.
struct AvailableUpdate: Equatable {
    var version: String
    var storeURL: URL
}

/// Checks a small JSON file published on the docs site for the latest released
/// marketing version and, once per version, invites the user to update via the
/// App Store. Any failure (offline, malformed, missing platform URL) is
/// swallowed on purpose: an update check must never disturb launch.
@MainActor
@Observable
final class UpdateChecker {
    /// Set when a newer, not-yet-prompted version is found. The UI observes
    /// this to present the alert, then clears it.
    var available: AvailableUpdate?

    @ObservationIgnored private let defaults = UserDefaults.standard
    @ObservationIgnored private let session: URLSession
    private static let lastPromptedKey = "update.lastPromptedVersion"

    /// The appcast published by the docs site (GitLab Pages).
    private static let appcastURL = URL(string: "https://kourser.pages.git.cythin.eu/skingomz-app/appcast.json")!

    init(session: URLSession = .shared) {
        self.session = session
    }

    /// Current marketing version, e.g. "0.3.0" (mirrors what Settings shows).
    private static var currentVersion: String {
        Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
    }

    func check() async {
        do {
            let (data, _) = try await session.data(from: Self.appcastURL)
            let appcast = try JSONDecoder().decode(Appcast.self, from: data)
            #if os(macOS)
            let storeURL = appcast.urls.macos
            #else
            let storeURL = appcast.urls.ios
            #endif
            guard let storeURL,
                  Self.isNewer(appcast.version, than: Self.currentVersion),
                  // Once per version: don't renag for a version already prompted.
                  appcast.version != defaults.string(forKey: Self.lastPromptedKey)
            else { return }
            defaults.set(appcast.version, forKey: Self.lastPromptedKey)
            available = AvailableUpdate(version: appcast.version, storeURL: storeURL)
        } catch {
            // Silent by design.
        }
    }

    /// Compares dot-separated numeric versions ("0.3.0" vs "0.10.0"), padding
    /// the shorter with zeros. Non-numeric components sort as 0.
    static func isNewer(_ lhs: String, than rhs: String) -> Bool {
        let a = components(lhs), b = components(rhs)
        for i in 0..<max(a.count, b.count) {
            let l = i < a.count ? a[i] : 0
            let r = i < b.count ? b[i] : 0
            if l != r { return l > r }
        }
        return false
    }

    private static func components(_ v: String) -> [Int] {
        v.split(separator: ".").map { Int($0) ?? 0 }
    }
}

docs/appcast.json

0 → 100644
+7 −0
Original line number Diff line number Diff line
{
  "version": "0.3.0",
  "urls": {
    "ios": "https://apps.apple.com/fr/app/skingomz/id6782177493",
    "macos": "https://apps.apple.com/fr/app/skingomz/id6782177493?platform=mac"
  }
}