🎉 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:
2025-06-28 16:15:15 +02:00
commit 0dabed11d2
63 changed files with 25727 additions and 0 deletions

View 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
}
}