🎉 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.
This commit is contained in:
446
ShotScreen/Sources/UpdateManager.swift
Normal file
446
ShotScreen/Sources/UpdateManager.swift
Normal file
@@ -0,0 +1,446 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user