import Foundation import AppKit import Sparkle // MARK: - Update Manager Delegate Protocol protocol UpdateManagerDelegate: AnyObject { func updateCheckDidStart() func updateCheckDidFinish() func updateAvailable(_ update: SUAppcastItem) func noUpdateAvailable() func updateCheckFailed(error: Error) } // MARK: - Custom Update Check Result struct CustomUpdateCheckResult { let hasUpdate: Bool let latestVersion: String? let currentVersion: String let downloadURL: String? let releaseNotes: String? } // MARK: - Update Manager class UpdateManager: NSObject { // MARK: - Properties static let shared = UpdateManager() private var updaterController: SPUStandardUpdaterController? weak var delegate: UpdateManagerDelegate? private var isManualCheck = false // Track if this is a manual check private let feedURL = "https://git.plet.i234.me/Nick/shotscreen/raw/branch/main/appcast.xml" // MARK: - Initialization override init() { super.init() setupUpdater() } // MARK: - Setup private func setupUpdater() { print("šŸ”„ UPDATE: Initializing Sparkle updater...") // Check if we're in a development build let isDevelopmentBuild = isRunningInDevelopment() print("šŸ”§ UPDATE: Development build detected: \(isDevelopmentBuild)") // Additional debug info print("šŸ” UPDATE: Current app version: \(currentVersion)") print("šŸ” UPDATE: Current build number: \(currentBuildNumber)") print("šŸ” UPDATE: Bundle path: \(Bundle.main.bundlePath)") print("šŸ” UPDATE: Bundle URL: \(Bundle.main.bundleURL)") print("šŸ” UPDATE: Executable path: \(CommandLine.arguments[0])") // Initialize Sparkle with standard configuration (for automatic updates only) updaterController = SPUStandardUpdaterController( startingUpdater: !isDevelopmentBuild, // Don't auto-start for dev builds updaterDelegate: self, userDriverDelegate: nil // Use standard driver for automatic checks ) // Configure the updater if let updater = updaterController?.updater { // Configure automatic updates based on user settings and build type let automaticUpdatesEnabled = !isDevelopmentBuild && SettingsManager.shared.automaticUpdates updater.automaticallyChecksForUpdates = automaticUpdatesEnabled updater.automaticallyDownloadsUpdates = false // Always ask user print("āœ… UPDATE: Sparkle updater initialized successfully") print("šŸ”— UPDATE: Feed URL configured via Info.plist: \(feedURL)") print("šŸ¤– UPDATE: Automatic checks: \(updater.automaticallyChecksForUpdates)") print("āš™ļø UPDATE: SettingsManager automatic updates: \(SettingsManager.shared.automaticUpdates)") print("šŸš€ UPDATE: Updater started: \(!isDevelopmentBuild)") // Force an immediate update check if this is a manual debug print("šŸ” UPDATE: Last update check date: \(updater.lastUpdateCheckDate?.description ?? "Never")") } else { print("āŒ UPDATE: Failed to access Sparkle updater") } } // MARK: - Development Detection private func isRunningInDevelopment() -> Bool { // Check if we're running from .build directory (swift run) let executablePath = CommandLine.arguments[0] let isDevelopment = executablePath.contains(".build") || executablePath.contains("DerivedData") || Bundle.main.bundleURL.pathExtension != "app" print("šŸ” UPDATE: Executable path: \(executablePath)") print("šŸ” UPDATE: Bundle URL: \(Bundle.main.bundleURL)") return isDevelopment } // MARK: - Public Interface /// Manually check for updates (triggered by user) - USE SPARKLE'S NATIVE APPROACH func checkForUpdates() { print("šŸ” UPDATE: === checkForUpdates() START ===") print("šŸ” UPDATE: updaterController exists: \(updaterController != nil)") if let controller = updaterController { print("šŸ” UPDATE: updaterController.updater exists: true") } guard let updater = updaterController?.updater else { print("āŒ UPDATE: Updater not available - showing fallback") showManualUpdateFallback() return } print("šŸ” UPDATE: Manual update check triggered (using Sparkle's native method)") print("šŸ” UPDATE: Current version from bundle: \(currentVersion)") print("šŸ” UPDATE: Current build number: \(currentBuildNumber)") print("šŸ” UPDATE: Feed URL: \(feedURL)") print("šŸ” UPDATE: Automatic checks enabled: \(updater.automaticallyChecksForUpdates)") print("šŸ” UPDATE: Last check date: \(updater.lastUpdateCheckDate?.description ?? "Never")") isManualCheck = true delegate?.updateCheckDidStart() print("šŸ” UPDATE: About to call updater.checkForUpdates()") // Use Sparkle's own checkForUpdates method (this should work reliably) updater.checkForUpdates() print("šŸ” UPDATE: updater.checkForUpdates() called successfully") } // MARK: - Custom Update Check Implementation (REMOVED - now only used as fallback) private func performCustomUpdateCheckFallback() { guard let url = URL(string: feedURL) else { print("āŒ UPDATE: Invalid feed URL") delegate?.updateCheckFailed(error: NSError(domain: "UpdateManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid feed URL"])) isManualCheck = false return } print("šŸ” UPDATE: Performing fallback update check from: \(feedURL)") // Create URL session let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in DispatchQueue.main.async { self?.handleCustomUpdateCheckResponse(data: data, response: response, error: error) } } task.resume() } private func handleCustomUpdateCheckResponse(data: Data?, response: URLResponse?, error: Error?) { defer { isManualCheck = false delegate?.updateCheckDidFinish() } if let error = error { print("āŒ UPDATE: Network error: \(error.localizedDescription)") delegate?.updateCheckFailed(error: error) return } guard let data = data else { print("āŒ UPDATE: No data received") let error = NSError(domain: "UpdateManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "No data received"]) delegate?.updateCheckFailed(error: error) return } print("āœ… UPDATE: Received appcast data (\(data.count) bytes)") // Parse the appcast XML parseAppcastAndCheckForUpdates(data: data) } private func parseAppcastAndCheckForUpdates(data: Data) { do { let xml = try XMLDocument(data: data, options: []) // Find the latest item in the appcast guard let latestItem = xml.rootElement()?.elements(forName: "channel").first?.elements(forName: "item").first else { print("āŒ UPDATE: No items found in appcast") delegate?.noUpdateAvailable() return } // Extract version information guard let enclosure = latestItem.elements(forName: "enclosure").first, let versionAttribute = enclosure.attribute(forName: "sparkle:version"), let latestVersionString = versionAttribute.stringValue else { print("āŒ UPDATE: Could not extract version from appcast") delegate?.noUpdateAvailable() return } let currentVersionString = currentVersion print("šŸ” UPDATE: Current version: \(currentVersionString)") print("šŸ” UPDATE: Latest version: \(latestVersionString)") // Compare versions let hasUpdate = compareVersions(current: currentVersionString, latest: latestVersionString) if hasUpdate { print("šŸŽ‰ UPDATE: New version available!") // Create a mock SUAppcastItem for compatibility if let mockItem = createMockAppcastItem(from: latestItem, version: latestVersionString) { delegate?.updateAvailable(mockItem) } else { // Fallback to custom update available notification showCustomUpdateAvailable(latestVersion: latestVersionString, currentVersion: currentVersionString) } } else { print("ā„¹ļø UPDATE: No update available (current: \(currentVersionString), latest: \(latestVersionString))") delegate?.noUpdateAvailable() } } catch { print("āŒ UPDATE: XML parsing error: \(error.localizedDescription)") delegate?.updateCheckFailed(error: error) } } private func compareVersions(current: String, latest: String) -> Bool { // Handle "Unknown" version (development builds) if current == "Unknown" { print("šŸ” UPDATE: Skipping update check for development build") return false // Don't offer updates for development builds } print("šŸ” UPDATE: Comparing versions - Current: '\(current)' vs Latest: '\(latest)'") // Simple version comparison (assuming semantic versioning like "1.0", "1.1", etc.) let currentComponents = current.split(separator: ".").compactMap { Int($0) } let latestComponents = latest.split(separator: ".").compactMap { Int($0) } print("šŸ” UPDATE: Current components: \(currentComponents)") print("šŸ” UPDATE: Latest components: \(latestComponents)") // Pad arrays to same length let maxLength = max(currentComponents.count, latestComponents.count) var currentPadded = currentComponents var latestPadded = latestComponents while currentPadded.count < maxLength { currentPadded.append(0) } while latestPadded.count < maxLength { latestPadded.append(0) } print("šŸ” UPDATE: Current padded: \(currentPadded)") print("šŸ” UPDATE: Latest padded: \(latestPadded)") // Compare component by component for i in 0.. currentPadded[i] { print("šŸŽ‰ UPDATE: Found newer version at component \(i): \(latestPadded[i]) > \(currentPadded[i])") return true } else if latestPadded[i] < currentPadded[i] { print("āš ļø UPDATE: Latest version is older at component \(i): \(latestPadded[i]) < \(currentPadded[i])") return false } } print("ā„¹ļø UPDATE: Versions are equal") return false // Versions are equal } private func createMockAppcastItem(from xmlElement: XMLElement, version: String) -> SUAppcastItem? { // This is a bit tricky as SUAppcastItem is not easily creatable // For now, we'll use our custom update available method return nil } private func showCustomUpdateAvailable(latestVersion: String, currentVersion: String) { DispatchQueue.main.async { [weak self] in let alert = NSAlert() alert.messageText = "Update Available! šŸŽ‰" alert.informativeText = """ ShotScreen \(latestVersion) is now available. You have version \(currentVersion). Would you like to visit the releases page to download it? """ alert.addButton(withTitle: "Download Update") alert.addButton(withTitle: "Skip This Version") alert.addButton(withTitle: "Remind Me Later") alert.alertStyle = .informational // Set app icon if let appIcon = NSApplication.shared.applicationIconImage { alert.icon = appIcon } let response = alert.runModal() switch response { case .alertFirstButtonReturn: print("āœ… UPDATE: User chose to download update") if let url = URL(string: "https://git.plet.i234.me/Nick/shotscreen/releases") { NSWorkspace.shared.open(url) } case .alertSecondButtonReturn: print("āŒ UPDATE: User chose to skip this version") case .alertThirdButtonReturn: print("ā° UPDATE: User chose to be reminded later") default: break } } } /// Update automatic update settings func updateAutomaticUpdateSettings() { guard let updater = updaterController?.updater else { return } let automaticUpdates = SettingsManager.shared.automaticUpdates updater.automaticallyChecksForUpdates = automaticUpdates print("āš™ļø UPDATE: Automatic updates \(automaticUpdates ? "enabled" : "disabled")") } /// Check if updater is available var isUpdaterAvailable: Bool { return updaterController?.updater != nil } /// Get current app version var currentVersion: String { return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" } /// Get current build number var currentBuildNumber: String { return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" } // MARK: - Private Methods private func showManualUpdateFallback() { print("šŸ”„ UPDATE: Showing manual update fallback") let alert = NSAlert() alert.messageText = "Check for Updates" alert.informativeText = "Automatic update checking is currently unavailable. You can check for updates manually on our releases page." alert.addButton(withTitle: "Open Releases Page") alert.addButton(withTitle: "Cancel") alert.alertStyle = .informational let response = alert.runModal() if response == .alertFirstButtonReturn { if let url = URL(string: "https://git.plet.i234.me/Nick/shotscreen/releases") { NSWorkspace.shared.open(url) } } } // MARK: - Debug Info func printDebugInfo() { print("\nšŸ” UPDATE DEBUG INFO:") print("šŸ“± Current Version: \(currentVersion)") print("šŸ”¢ Build Number: \(currentBuildNumber)") print("šŸ”— Feed URL: \(feedURL)") print("⚔ Updater Available: \(isUpdaterAvailable)") print("šŸ¤– Automatic Updates: \(SettingsManager.shared.automaticUpdates)") if let updater = updaterController?.updater { print("šŸ“… Last Check: \(updater.lastUpdateCheckDate?.description ?? "Never")") } print("šŸ” UPDATE DEBUG INFO END\n") } /// Force a debug update check - bypasses development detection temporarily func forceDebugUpdateCheck() { print("šŸ”§ DEBUG: Forcing update check (bypassing development detection)") // Temporarily create a new updater controller that ignores development mode let debugController = SPUStandardUpdaterController( startingUpdater: true, // Always start for debug updaterDelegate: self, userDriverDelegate: nil ) let debugUpdater = debugController.updater print("šŸ”§ DEBUG: Debug updater created successfully") debugUpdater.automaticallyChecksForUpdates = false debugUpdater.automaticallyDownloadsUpdates = false isManualCheck = true delegate?.updateCheckDidStart() print("šŸ”§ DEBUG: Starting forced update check...") debugUpdater.checkForUpdates() } } // MARK: - SPUUpdaterDelegate (automatic updates only - manual updates use custom implementation) extension UpdateManager: @preconcurrency SPUUpdaterDelegate { func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) { // This is only called for automatic background checks now (not manual checks) print("āœ… UPDATE: Automatic update check completed") if let error = error { print("āŒ UPDATE: Automatic update check failed: \(error.localizedDescription)") } // Let Sparkle handle everything - no delegate calls to prevent UI interference isManualCheck = false } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { print("šŸŽ‰ UPDATE: Sparkle found valid update!") print("šŸ” UPDATE: Update version: \(item.displayVersionString)") print("šŸ” UPDATE: Current version: \(currentVersion)") print("šŸ” UPDATE: Update info: \(item.infoURL?.absoluteString ?? "None")") print("šŸ” UPDATE: Was manual check: \(isManualCheck)") // Let Sparkle handle the UI completely - don't trigger additional dialogs // Just notify delegate for internal state tracking (no additional UI) isManualCheck = false // delegate?.updateAvailable(item) // Commented out to prevent double UI } func updaterDidNotFindUpdate(_ updater: SPUUpdater) { print("ā„¹ļø UPDATE: No updates found by Sparkle") print("šŸ” UPDATE: Current version: \(currentVersion)") print("šŸ” UPDATE: Feed URL used: \(feedURL)") print("šŸ” UPDATE: Was manual check: \(isManualCheck)") // Let Sparkle handle the "no update" UI completely - don't add our own popup // Sparkle already shows its own "You're up to date!" dialog for manual checks isManualCheck = false // delegate?.noUpdateAvailable() // Commented out to prevent multiple dialogs } func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { print("šŸ“¦ UPDATE: Will install update: \(item.displayVersionString)") } func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { print("šŸ’„ UPDATE: Update aborted with error: \(error.localizedDescription)") // For automatic updates, we don't need to notify the delegate of failures // since they're silent background operations } }