vibetunnel/VibeTunnel/Core/Services/SparkleUpdaterManager.swift
2025-06-19 14:32:14 +02:00

221 lines
7.9 KiB
Swift

import Foundation
import Observation
import os.log
import Sparkle
import UserNotifications
/// SparkleUpdaterManager with automatic update downloads enabled.
///
/// Manages application updates using the Sparkle framework. Handles automatic
/// update checking, downloading, and installation while respecting user preferences
/// and update channels. Integrates with macOS notifications for update announcements.
@available(macOS 10.15, *)
@MainActor
public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
public static let shared = SparkleUpdaterManager()
fileprivate var updaterController: SPUStandardUpdaterController?
private let logger = os.Logger(
subsystem: Bundle.main.bundleIdentifier ?? "VibeTunnel",
category: "SparkleUpdater"
)
override public init() {
super.init()
// Skip initialization during tests
let isRunningInTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil ||
ProcessInfo.processInfo.environment["XCTestBundlePath"] != nil ||
ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil ||
ProcessInfo.processInfo.arguments.contains("-XCTest") ||
NSClassFromString("XCTestCase") != nil
if isRunningInTests {
logger.info("Running in test mode, skipping Sparkle initialization")
return
}
// Check if installed from App Store
if ProcessInfo.processInfo.installedFromAppStore {
logger.info("App installed from App Store, skipping Sparkle initialization")
return
}
// Initialize Sparkle with standard configuration
#if DEBUG
// In debug mode, don't start the updater automatically
updaterController = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: self,
userDriverDelegate: nil
)
#else
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: nil
)
#endif
// Configure automatic updates
if let updater = updaterController?.updater {
#if DEBUG
// Disable automatic checks in debug builds
updater.automaticallyChecksForUpdates = false
updater.automaticallyDownloadsUpdates = false
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates disabled")
#else
// Enable automatic checking for updates
updater.automaticallyChecksForUpdates = true
// Enable automatic downloading of updates
updater.automaticallyDownloadsUpdates = true
// Set update check interval to 24 hours
updater.updateCheckInterval = 86_400
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
// Start the updater
if let controller = updaterController {
do {
try controller.updater.start()
} catch {
logger.error("Failed to start Sparkle updater: \(error)")
}
}
#endif
// Note: feedURL configuration happens through delegate methods
}
}
public func setUpdateChannel(_ channel: UpdateChannel) {
// Save the channel preference
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
logger.info("Update channel set to: \(channel.rawValue)")
// The actual feed URL will be provided by the delegate method
}
public func checkForUpdatesInBackground() {
guard let updater = updaterController?.updater else { return }
updater.checkForUpdatesInBackground()
logger.info("Background update check initiated")
}
public func checkForUpdates() {
guard updaterController != nil else {
logger.warning("Cannot check for updates: updater not initialized")
return
}
updaterController?.checkForUpdates(nil)
logger.info("Manual update check initiated")
}
public func clearUserDefaults() {
let sparkleDefaults = [
"SUEnableAutomaticChecks",
"SUHasLaunchedBefore",
"SULastCheckTime",
"SUSendProfileInfo",
"SUUpdateRelaunchingMarker",
"SUAutomaticallyUpdate",
"SULastProfileSubmissionDate"
]
for key in sparkleDefaults {
UserDefaults.standard.removeObject(forKey: key)
}
logger.info("Sparkle user defaults cleared")
}
}
// MARK: - SPUUpdaterDelegate
extension SparkleUpdaterManager {
public nonisolated func updater(_ updater: SPUUpdater, mayPerformUpdateCheck updateCheck: SPUUpdateCheck) throws {
// Allow update checks by default - not throwing an error means the check is allowed
// We could add logic here to prevent checks during certain conditions
}
public nonisolated func allowedChannels(for updater: SPUUpdater) -> Set<String> {
// Get the current update channel from UserDefaults
if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: savedChannel)
{
return channel.includesPreReleases ? Set(["", "prerelease"]) : Set([""])
}
return Set([""]) // Default to stable channel only
}
public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
// Provide the appropriate feed URL based on the current update channel
if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: savedChannel)
{
return channel.appcastURL.absoluteString
}
return UpdateChannel.defaultChannel.appcastURL.absoluteString
}
}
// MARK: - SparkleViewModel
@MainActor
@available(macOS 10.15, *)
@Observable
public final class SparkleViewModel {
public var canCheckForUpdates = false
public var isCheckingForUpdates = false
public var automaticallyChecksForUpdates = true
public var automaticallyDownloadsUpdates = true
public var updateCheckInterval: TimeInterval = 86_400
public var lastUpdateCheckDate: Date?
public var updateChannel: UpdateChannel = .stable
private let updaterManager = SparkleUpdaterManager.shared
public init() {
// Sync with actual Sparkle settings
if let updater = updaterManager.updaterController?.updater {
automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates
automaticallyDownloadsUpdates = updater.automaticallyDownloadsUpdates
updateCheckInterval = updater.updateCheckInterval
lastUpdateCheckDate = updater.lastUpdateCheckDate
canCheckForUpdates = updater.canCheckForUpdates
}
// Load saved update channel
if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: savedChannel)
{
updateChannel = channel
} else {
updateChannel = UpdateChannel.stable
}
}
public func checkForUpdates() {
updaterManager.checkForUpdates()
}
public func setUpdateChannel(_ channel: UpdateChannel) {
updateChannel = channel
updaterManager.setUpdateChannel(channel)
}
}
// MARK: - ProcessInfo Extension
extension ProcessInfo {
fileprivate var installedFromAppStore: Bool {
// Check for App Store receipt
let receiptURL = Bundle.main.appStoreReceiptURL
if let receiptURL {
return receiptURL.lastPathComponent == "receipt" && FileManager.default.fileExists(atPath: receiptURL.path)
}
return false
}
}