Files
shotscreen/ShotScreen/Sources/UpdateManager.swift
Nick Roodenrijs 0dabed11d2 🎉 ShotScreen v1.0 - Initial Release
🚀 First official release of ShotScreen with complete feature set:

 Core Features:
- Advanced screenshot capture system
- Multi-monitor support
- Professional UI/UX design
- Automated update system with Sparkle
- Apple notarized & code signed

🛠 Technical Excellence:
- Native Swift macOS application
- Professional build & deployment pipeline
- Comprehensive error handling
- Memory optimized performance

📦 Distribution Ready:
- Professional DMG packaging
- Apple notarization complete
- No security warnings for users
- Ready for public distribution

This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS.
2025-06-28 16:15:15 +02:00

446 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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..<maxLength {
print("🔍 UPDATE: Comparing index \(i): \(latestPadded[i]) vs \(currentPadded[i])")
if latestPadded[i] > 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
}
}