🚀 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.
446 lines
19 KiB
Swift
446 lines
19 KiB
Swift
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
|
||
}
|
||
} |