mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
221 lines
7.9 KiB
Swift
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
|
|
}
|
|
}
|