diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 39775019..ae6350b2 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,9 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28492A2F22B4B700F6CE42 /* Scribe */; }; 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; }; 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; }; - 3EFD33252EE6DFA4007D9601 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 3EFD33242EE6DFA4007D9601 /* Scribe */; }; + 2A8EDDBC2F23743B005457F8 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A8EDDBB2F23743B005457F8 /* Scribe */; }; + 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; A883642F298B7288005D6C19 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A883642E298B7288005D6C19 /* ServiceManagement.framework */; }; F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; }; /* End PBXBuildFile section */ @@ -55,11 +57,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 3EFD33252EE6DFA4007D9601 /* Scribe in Frameworks */, 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */, 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */, + 2A8EDDBC2F23743B005457F8 /* Scribe in Frameworks */, + 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, A883642F298B7288005D6C19 /* ServiceManagement.framework in Frameworks */, + 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,7 +123,9 @@ packageProductDependencies = ( 2A28B6282EE5050C00A1E26B /* Defaults */, 2A28B62B2EE5057C00A1E26B /* Luminare */, - 3EFD33242EE6DFA4007D9601 /* Scribe */, + 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, + 2A28492A2F22B4B700F6CE42 /* Scribe */, + 2A8EDDBB2F23743B005457F8 /* Scribe */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -132,7 +138,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 2600; TargetAttributes = { A8E59C34297F5E9A0064D4BA = { @@ -163,7 +169,8 @@ packageReferences = ( 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */, 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, - 3EFD33232EE6DFA4007D9601 /* XCRemoteSwiftPackageReference "Scribe" */, + 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + 2A8EDDBA2F23743B005457F8 /* XCRemoteSwiftPackageReference "Scribe" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -543,7 +550,7 @@ kind = branch; }; }; - 3EFD33232EE6DFA4007D9601 /* XCRemoteSwiftPackageReference "Scribe" */ = { + 2A8EDDBA2F23743B005457F8 /* XCRemoteSwiftPackageReference "Scribe" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SenpaiHunters/Scribe"; requirement = { @@ -551,9 +558,21 @@ kind = branch; }; }; + 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.20; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2A28492A2F22B4B700F6CE42 /* Scribe */ = { + isa = XCSwiftPackageProductDependency; + productName = Scribe; + }; 2A28B6282EE5050C00A1E26B /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */; @@ -564,11 +583,16 @@ package = 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */; productName = Luminare; }; - 3EFD33242EE6DFA4007D9601 /* Scribe */ = { + 2A8EDDBB2F23743B005457F8 /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 3EFD33232EE6DFA4007D9601 /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A8EDDBA2F23743B005457F8 /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; + 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A8E59C2D297F5E9A0064D4BA /* Project object */; diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index c1202c97..9c03a9d2 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -23,10 +23,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_: Notification) { configureLogging() + // Check for and terminate other running Loop instances to prevent accessibility conflicts + terminateOtherLoopInstances() + Task { await Defaults.iCloud.waitForSyncCompletion() } + // Wait for other instances to terminate before proceeding with TCC operations + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + AccessibilityManager.requestAccess() + } + if !launchedAsLoginItem { SettingsWindowManager.shared.show() } else { @@ -51,10 +59,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { UNUserNotificationCenter.current().delegate = self AppDelegate.requestNotificationAuthorization() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - AccessibilityManager.requestAccess() - } - // Register for URL handling NSAppleEventManager.shared().setEventHandler( self, @@ -64,6 +68,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) } + /// Terminates any other running instances of Loop to prevent accessibility permission conflicts. + private func terminateOtherLoopInstances() { + let currentProcessId = ProcessInfo.processInfo.processIdentifier + let bundleId = Bundle.main.bundleIdentifier ?? "com.MrKai77.Loop" + + let runningApps = NSWorkspace.shared.runningApplications + let otherLoopInstances = runningApps.filter { + $0.bundleIdentifier == bundleId && $0.processIdentifier != currentProcessId + } + + guard !otherLoopInstances.isEmpty else { + Log.info("No other Loop instances found", category: .appDelegate) + return + } + + Log.info("Found \(otherLoopInstances.count) other Loop instance(s), terminating them to prevent accessibility conflicts. TCC operations will be delayed.", category: .appDelegate) + + for instance in otherLoopInstances { + Log.info("Terminating Loop instance (PID: \(instance.processIdentifier))", category: .appDelegate) + instance.terminate() + + // If the instance doesn't terminate within 2 seconds, force terminate + Task { + try? await Task.sleep(for: .seconds(2)) + + if instance.isTerminated == false { + Log.warn("Force terminating Loop instance (PID: \(instance.processIdentifier))", category: .appDelegate) + instance.forceTerminate() + } + } + } + + // Give the other instances time to terminate cleanly + Thread.sleep(forTimeInterval: 1.0) + } + /// Applies baseline logging configuration for Scribe. private func configureLogging() { LogManager.shared.configuration.includeFileAndLineNumber = false @@ -93,15 +133,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { StashManager.shared.onApplicationWillTerminate() } - static func relaunch(after seconds: TimeInterval = 0.5) -> Never { - let task = Process() - task.launchPath = "/bin/sh" - task.arguments = ["-c", "sleep \(seconds); open \"\(Bundle.main.bundlePath)\""] - task.launch() - NSApp.terminate(nil) - exit(0) - } - func application(_: NSApplication, open urls: [URL]) { for url in urls { urlCommandHandler.handle(url) diff --git a/Loop/App/LoopApp.swift b/Loop/App/LoopApp.swift index 81ad5726..00626806 100644 --- a/Loop/App/LoopApp.swift +++ b/Loop/App/LoopApp.swift @@ -27,7 +27,7 @@ struct LoopApp: App { Divider() Text( - "Version \(Bundle.main.appVersion ?? "Unknown") (\(Bundle.main.appBuild ?? 0))", + "Version \(VersionDisplay.current.fullDisplay)", comment: "Format: Version [version, e.g. 1.3.0] ([build number, e.g. 1500])" ) .font(.system(size: 11, weight: .semibold)) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index ebdb508c..ca6ed1ac 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -189,7 +189,7 @@ extension LoopManager { } Task { - if updater.shouldAutoPresentUpdateWindow { + if await updater.shouldAutoPresentUpdateWindow { await updater.showUpdateWindowIfEligible() } } diff --git a/Loop/Extensions/Bundle+Extensions.swift b/Loop/Extensions/Bundle+Extensions.swift index 923a5bf8..9f74d42c 100644 --- a/Loop/Extensions/Bundle+Extensions.swift +++ b/Loop/Extensions/Bundle+Extensions.swift @@ -33,6 +33,10 @@ extension Bundle { getInfo("CFBundleShortVersionString") } + var bundleURL: URL { + URL(fileURLWithPath: bundlePath) + } + func getInfo(_ str: String) -> String? { infoDictionary?[str] as? String } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 1039c6db..1aa25fd9 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -89,6 +89,7 @@ extension Defaults.Keys { // Development versions should check for development updates by default. static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: true, iCloud: true) #endif + static let automaticallyUpdate = Key("automaticallyUpdate", default: false, iCloud: true) } // MARK: - Hidden Settings diff --git a/Loop/Extensions/Int64+Extensions.swift b/Loop/Extensions/Int64+Extensions.swift new file mode 100644 index 00000000..27f280e7 --- /dev/null +++ b/Loop/Extensions/Int64+Extensions.swift @@ -0,0 +1,21 @@ +// +// Int64+Extensions.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import Foundation + +extension Int64 { + var formattedBytes: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB] + formatter.countStyle = .file + formatter.includesUnit = true + formatter.includesCount = true + formatter.isAdaptive = false // Always use MB, no GB/KB + formatter.allowsNonnumericFormatting = false + return formatter.string(fromByteCount: self) + } +} diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 763a12a0..34db9005 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -249,6 +249,89 @@ } } }, + "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" : { + "comment" : "Informative text in the alert displayed when the app is not in the user's Applications folder.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ne se trouve pas dans votre dossier Applications. Voulez-vous installer la mise à jour dans le dossier Applications à la place ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ はアプリケーションフォルダにありません。代わりにアプリケーションフォルダにアップデートをインストールしますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + } + } + } + }, "%@ places windows slightly above the absolute center,\nwhich can be found more ergonomic." : { "localizations" : { "ar" : { @@ -1044,8 +1127,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Allow radial menu customization" + "state" : "translated", + "value" : "Radiaal menu aanpassen toestaan" } }, "pt-BR" : { @@ -1731,6 +1814,89 @@ } } }, + "Automatically install updates" : { + "comment" : "A tooltip displayed when hovering over the toggle.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically install updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer les mises à jour automatiquement" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動的にアップデートをインストールする" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically install updates" + } + } + } + }, "Background" : { "comment" : "Section header for configuring the background of the preview.", "localizations" : { @@ -1784,8 +1950,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Background" + "state" : "translated", + "value" : "Achtergrond" } }, "pt-BR" : { @@ -2363,8 +2529,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Bottom Screen" + "state" : "translated", + "value" : "Onderste scherm" } }, "pt-BR" : { @@ -3356,8 +3522,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Configure…" + "state" : "translated", + "value" : "Configureren" } }, "pt-BR" : { @@ -3686,8 +3852,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Corner Radius" + "state" : "translated", + "value" : "Hoekradius" } }, "pt-BR" : { @@ -4761,8 +4927,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Default corner radius" + "state" : "translated", + "value" : "Standaard hoekradius" } }, "pt-BR" : { @@ -5338,8 +5504,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Enable blur" + "state" : "translated", + "value" : "Vervaging" } }, "pt-BR" : { @@ -5421,8 +5587,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Enable window snapping" + "state" : "translated", + "value" : "Venster" } }, "pt-BR" : { @@ -10045,7 +10211,7 @@ "nl-BE" : { "stringUnit" : { "state" : "translated", - "value" : "5000 loops veroverd! Het universum is een getuige van de geboorte van een Loop meester! Geniet van je wel verdiende cadeau: een nieuwe icoon!" + "value" : "5000 Loops veroverd! Het universum is getuige van de geboorte van een Loop-meester! Geniet van je welverdiende cadeau: een nieuwe icoon!" } }, "pt-BR" : { @@ -10895,6 +11061,172 @@ } } }, + "Install failed" : { + "comment" : "The text that appears when the update fails to install.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install failed" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de l’installation" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストールに失敗しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install failed" + } + } + } + }, + "Install to Applications" : { + "comment" : "Title of the first button in an alert that asks if the user wants to install an update to their Applications folder.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install to Applications" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer dans Applications" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリケーションにインストール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Install to Applications" + } + } + } + }, "Instant" : { "comment" : "Animation speed setting", "localizations" : { @@ -11060,6 +11392,89 @@ } } }, + "Keep in Current Location" : { + "comment" : "Button title in an alert that lets the user keep the update installed in its current location.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep in Current Location" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Garder dans l'emplacement actuel" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在の場所のままにする" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Keep in Current Location" + } + } + } + }, "Keybinds" : { "comment" : "Section header shown in settings", "localizations" : { @@ -15800,43 +16215,126 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "右に移動" + "value" : "右に移動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오른쪽으로 이동" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naar rechts" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para a Direita" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вправо" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向右移动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向右移動" + } + } + } + }, + "Move to Applications Folder?" : { + "comment" : "Title of an alert that asks the user to move the application to its default location.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Move to Applications Folder?" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Move to Applications Folder?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to Applications Folder?" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Move to Applications Folder?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers le dossier Applications ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Move to Applications Folder?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリケーションフォルダに移動しますか?" } }, "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "오른쪽으로 이동" + "state" : "needs_review", + "value" : "Move to Applications Folder?" } }, "nl-BE" : { "stringUnit" : { - "state" : "translated", - "value" : "Naar rechts" + "state" : "needs_review", + "value" : "Move to Applications Folder?" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", - "value" : "Mover para a Direita" + "state" : "needs_review", + "value" : "Move to Applications Folder?" } }, "ru" : { "stringUnit" : { - "state" : "translated", - "value" : "Вправо" + "state" : "needs_review", + "value" : "Move to Applications Folder?" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "向右移动" + "state" : "needs_review", + "value" : "Move to Applications Folder?" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "向右移動" + "state" : "needs_review", + "value" : "Move to Applications Folder?" } } } @@ -20573,7 +21071,7 @@ "nl-BE" : { "stringUnit" : { "state" : "translated", - "value" : "Waarschuwing bij het ontgrendelen van nieuwe iconen" + "value" : "Notificatie bij het ontgrendelen van nieuwe iconen" } }, "pt-BR" : { @@ -21726,8 +22224,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Prioritize selected window’s corner radius" + "state" : "translated", + "value" : "Hoekradius van geselecteerde venster prioriseren" } }, "pt-BR" : { @@ -22223,7 +22721,7 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Herstarten om te voltooien" } }, @@ -28494,8 +28992,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Top" + "state" : "translated", + "value" : "Boven" } }, "pt-BR" : { @@ -29157,8 +29655,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Treat left and right keys differently" + "state" : "translated", + "value" : "Behandel linker- en rechtertoetsen anders" } }, "pt-BR" : { @@ -29352,6 +29850,89 @@ } } }, + "Try again later" : { + "comment" : "A button that is shown when an update fails to install.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again later" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réessayer plus tard" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "後で試す" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Try again later" + } + } + } + }, "Undo" : { "comment" : "Window action", "localizations" : { @@ -29600,6 +30181,89 @@ } } }, + "Update from: %@" : { + "comment" : "A label at the bottom of the update view that indicates the current app version and a message suggesting an update. The argument is the current app version.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update from: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version actuelle : %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在のバージョン: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Update from: %@" + } + } + } + }, "Update…" : { "comment" : "Button to update app in menubar dropdown menu", "localizations" : { @@ -29765,6 +30429,89 @@ } } }, + "Updates will only be installed when %@ is in the background." : { + "comment" : "A popover explaining that updates will only be installed when the app is in the background.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les mises à jour ne seront installées que lorsque %@ fonctionne en arrière-plan." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ がバックグラウンドで動作しているときのみ、アップデートがインストールされます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Updates will only be installed when %@ is in the background." + } + } + } + }, "Use coordinates" : { "localizations" : { "ar" : { @@ -29982,8 +30729,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Use macOS window manager when available" + "state" : "translated", + "value" : "Gebruik MacOS Window Manager als deze beschikbaar is" } }, "pt-BR" : { @@ -30176,85 +30923,85 @@ } } }, - "Version %@ (%lld)" : { + "Version %@" : { "comment" : "Format: Version [version, e.g. 1.3.0] ([build number, e.g. 1500])", "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "Version %1$@ (%2$lld)" + "value" : "Version %@" } }, "de" : { "stringUnit" : { - "state" : "translated", - "value" : "Version %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Version %1$@ (%2$lld)" + "value" : "Version %@" } }, "es" : { "stringUnit" : { - "state" : "translated", - "value" : "Versión %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Version %1$@ (%2$lld)" + "value" : "Version %@" } }, "it" : { "stringUnit" : { - "state" : "translated", - "value" : "Versione %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "バージョン %1$@ (%2$lld)" + "value" : "バージョン:%@" } }, "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "버전 %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "nl-BE" : { "stringUnit" : { - "state" : "translated", - "value" : "Versie %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", - "value" : "Versão %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "ru" : { "stringUnit" : { - "state" : "translated", - "value" : "Версия %1$@ (%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "版本 %1$@(%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "版本%1$@(%2$lld)" + "state" : "needs_review", + "value" : "Version %@" } } } diff --git a/Loop/Settings Window/Loop/AboutConfiguration.swift b/Loop/Settings Window/Loop/AboutConfiguration.swift index fdd7cd6d..1556a7ec 100644 --- a/Loop/Settings Window/Loop/AboutConfiguration.swift +++ b/Loop/Settings Window/Loop/AboutConfiguration.swift @@ -117,7 +117,7 @@ final class AboutConfigurationModel: ObservableObject { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString( - "Version \(Bundle.main.appVersion ?? "Unknown") (\(Bundle.main.appBuild ?? 0))", + "Version \(VersionDisplay.current.fullDisplay)", forType: NSPasteboard.PasteboardType.string ) @@ -157,6 +157,7 @@ struct AboutConfigurationView: View { @Default(.timesLooped) private var timesLooped @Default(.currentIcon) private var currentIcon @Default(.includeDevelopmentVersions) private var includeDevelopmentVersions + @Default(.automaticallyUpdate) private var automaticallyUpdate private var updateButtonEnabled: Bool { updater.updatesEnabled || model.isHoveringOverUpdateButton @@ -197,7 +198,7 @@ struct AboutConfigurationView: View { Text( model.isHoveringOverVersionCopier - ? "Version \(Bundle.main.appVersion ?? "Unknown") (\(Bundle.main.appBuild ?? 0))" + ? "Version \(Text(VersionDisplay.current.fullDisplay))" : (timesLooped >= 1_000_000 ? "You've looped… uhh… I… lost count…" : "You've looped \(timesLooped) times!") ) .contentTransition(.numericText(countsDown: !model.isHoveringOverVersionCopier)) @@ -239,8 +240,7 @@ struct AboutConfigurationView: View { LuminareSection { Button { Task { - // Pass force=true to bypass the guard check - await updater.fetchLatestInfo(force: true) + await updater.fetchLatestInfo(bypassUpdatesEnabled: true) switch updater.updateState { case .available: @@ -261,6 +261,16 @@ struct AboutConfigurationView: View { .onHover { model.isHoveringOverUpdateButton = $0 } LuminareToggle("Include development versions", isOn: $includeDevelopmentVersions) + + LuminareToggle(isOn: $automaticallyUpdate) { + Text("Automatically install updates") + .padding(.trailing, automaticallyUpdate ? 4 : 0) + .luminarePopover(attachedTo: .topTrailing, hidden: !automaticallyUpdate) { + Text("Updates will only be installed when \(Bundle.main.appName) is in the background.") + .padding(6) + } + .animation(luminareAnimation, value: automaticallyUpdate) + } } } diff --git a/Loop/Settings Window/SettingsTab.swift b/Loop/Settings Window/SettingsTab.swift index 560a0310..805ba745 100644 --- a/Loop/Settings Window/SettingsTab.swift +++ b/Loop/Settings Window/SettingsTab.swift @@ -9,7 +9,8 @@ import AppKit import Luminare import SwiftUI -enum SettingsTab: LuminareTabItem, CaseIterable { +@MainActor +enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { var id: String { title } case icon diff --git a/Loop/Updater/UpdateChecker.swift b/Loop/Updater/UpdateChecker.swift new file mode 100644 index 00000000..31052b1b --- /dev/null +++ b/Loop/Updater/UpdateChecker.swift @@ -0,0 +1,248 @@ +// +// UpdateChecker.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import Foundation +import Scribe + +@Loggable +actor UpdateChecker { + private let httpClient: HTTPClient = .init() + + private static let minimumOSRegex = /Minimum macOS version:\s*(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?/ + .ignoresCase() + private static let maximumOSRegex = /Maximum macOS version:\s*(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?/ + .ignoresCase() + private static let supportedArchitecturesRegex = /Supported architectures:\s*(?.+)/ + .ignoresCase() + + func checkForUpdate( + currentVersion: String, + currentBuild: Int = 0, + channel: UpdateChannel + ) async throws -> UpdateManifest? { + log.info("Checking for updates: \(currentVersion) build \(currentBuild) [\(channel.rawValue)]") + + let endpoint = URL(string: channel.githubReleasesEndpoint)! + var candidateRelease: GitHubRelease? + + let manifestData = try await httpClient.fetchData(from: endpoint) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + switch channel { + case .stable: + // Single release + candidateRelease = try decoder.decode(GitHubRelease.self, from: manifestData) + case .development: + // Multiple releases for dev channel + let releases = try decoder.decode([GitHubRelease].self, from: manifestData) + candidateRelease = releases.first(where: { $0.prerelease }) + } + + if let candidateRelease { + return try processRelease( + candidateRelease, + currentVersion: currentVersion, + currentBuild: currentBuild + ) + } + + log.info("No update available") + return nil + } + + private func processRelease( + _ release: GitHubRelease, + currentVersion: String, + currentBuild: Int + ) throws -> UpdateManifest? { + log.debug("Processing release: tagName='\(release.tagName)', name='\(release.name)', prerelease=\(release.prerelease)") + + // Extract version and build number from release + let (version, buildNumber) = extractVersionInfo(from: release) + + // Check if this is actually a newer version + log.debug("Checking version: current='\(currentVersion) (\(currentBuild))', available='\(version) (\(buildNumber))'") + guard isNewerVersion( + version, + buildNumber: buildNumber, + than: currentVersion, + currentBuild: currentBuild + ) else { + log.info("No newer version available") + return nil + } + + guard let asset = release.assets.first(where: { $0.name.hasSuffix(".zip") }) else { + log.error("No ZIP asset found in release") + return nil + } + + // Extract checksum from asset digest (format: "sha256:checksum") + let zipChecksum = asset.digest?.replacing(/sha256:/, with: "") ?? "" + log.debug("Asset digest: \(asset.digest ?? "none"), extracted checksum: \(zipChecksum)") + + let compatibility = extractCompatibilityRequirements(from: release.body) + + let manifest = UpdateManifest( + version: version, + buildNumber: buildNumber, + downloadUrl: asset.browserDownloadURL.absoluteString, + releaseNotes: UpdateManifest.ReleaseNotes( + title: release.name, + body: release.body + ), + checksums: UpdateManifest.Checksums( + zip: zipChecksum + ), + compatibility: UpdateManifest.Compatibility( + minimumOS: compatibility.minimumOS, + maximumOS: compatibility.maximumOS, + supportedArchitectures: compatibility.architectures + ), + channel: release.prerelease ? .development : .stable, + publishedAt: release.createdAt, + size: Int64(asset.size) + ) + + // Verify system requirements before returning the manifest + try verifySystemRequirements(manifest: manifest) + + log.info("Found update: \(manifest.version) (\(manifest.buildNumber))") + return manifest + } + + private func extractVersionInfo(from release: GitHubRelease) -> (version: String, buildNumber: Int) { + if release.prerelease { + // Parse from name field like "🧪 1.4.1 (1683)" + let regex = /🧪\s+(\d+\.\d+\.\d+)\s+\((\d+)\)/ + if let match = release.name.firstMatch(of: regex) { + let version = String(match.1) + let build = Int(String(match.2)) ?? 0 + log.debug("Parsed prerelease: version=\(version), build=\(build)") + return (version, build) + } + + log.warn("Could not parse prerelease version from: '\(release.name)'") + return ("0.0.0", 0) + } else { + // Stable release: tagName is the version + return (release.tagName, 0) + } + } + + private func isNewerVersion(_ newVersion: String, buildNumber: Int, than currentVersion: String, currentBuild: Int) -> Bool { + let versionComparison = newVersion.compare(currentVersion, options: .numeric) + + if versionComparison == .orderedDescending { + return true + } else if versionComparison == .orderedSame { + // For same version, only consider it newer if: + // 1. The release has a meaningful build number (> 0), AND + // 2. That build number is actually higher than current + // This prevents stable releases with build=0 from triggering updates for dev builds + return buildNumber > 0 && buildNumber > currentBuild + } + + return false + } + + private func extractCompatibilityRequirements( + from body: String + ) -> (minimumOS: OperatingSystemVersion?, maximumOS: OperatingSystemVersion?, architectures: [SystemInfo.Architecture]) { + var minimumOS: OperatingSystemVersion? + var maximumOS: OperatingSystemVersion? + var architectures: [SystemInfo.Architecture]? + + for line in body.split(whereSeparator: \.isNewline).reversed() { + let lineStr = String(line) + + // Extract minimum OS version + if minimumOS == nil, + let match = lineStr.firstMatch(of: Self.minimumOSRegex), + let major = Int(match.major) { + let minor = match.minor.flatMap { Int($0) } ?? 0 + let patch = match.patch.flatMap { Int($0) } ?? 0 + minimumOS = OperatingSystemVersion(majorVersion: major, minorVersion: minor, patchVersion: patch) + } + + // Extract maximum OS version + if maximumOS == nil, + let match = lineStr.firstMatch(of: Self.maximumOSRegex), + let major = Int(match.major) { + let minor = match.minor.flatMap { Int($0) } ?? 0 + let patch = match.patch.flatMap { Int($0) } ?? 0 + maximumOS = OperatingSystemVersion(majorVersion: major, minorVersion: minor, patchVersion: patch) + } + + // Extract supported architectures + if architectures == nil, + let match = lineStr.firstMatch(of: Self.supportedArchitecturesRegex) { + let archsString = String(match.archs) + var archs: [SystemInfo.Architecture] = [] + + if archsString.contains("arm64") { + archs.append(.arm64) + } + if archsString.contains("x86_64") { + archs.append(.x86_64) + } + + if !archs.isEmpty { + architectures = archs + } + } + + // Early exit if all values found + if minimumOS != nil, maximumOS != nil, architectures != nil { + break + } + } + + let finalArchitectures = architectures ?? SystemInfo.Architecture.allCases + + log.debug("Extracted compatibility: minOS=\(minimumOS?.description ?? "none"), maxOS=\(maximumOS?.description ?? "none"), archs=\(finalArchitectures.map(\.rawValue))") + + return (minimumOS, maximumOS, finalArchitectures) + } + + private func verifySystemRequirements(manifest: UpdateManifest) throws { + log.info("Verifying system requirements") + + if let minimumOS = manifest.compatibility.minimumOS { + guard ProcessInfo.processInfo.isOperatingSystemAtLeast(minimumOS) else { + throw UpdateError.incompatibleSystem("Update requires macOS \(minimumOS.description) or later") + } + log.debug("Minimum OS requirement check passed for version: \(minimumOS.description)") + } + + if let maximumOS = manifest.compatibility.maximumOS { + // Maximum OS is inclusive + // e.g. a max OS of 15.6.1 should allow Loop to be installed on 15.6.1, but not on 15.6.2 + let actualMaximumOS = OperatingSystemVersion( + majorVersion: maximumOS.majorVersion, + minorVersion: maximumOS.minorVersion, + patchVersion: maximumOS.patchVersion + 1 + ) + + guard !ProcessInfo.processInfo.isOperatingSystemAtLeast(actualMaximumOS) else { + throw UpdateError.incompatibleSystem("Update requires macOS \(maximumOS.description) or earlier") + } + + log.debug("Maximum OS requirement check passed for version: \(maximumOS.description)") + } + + let supportedArchitectures = manifest.compatibility.supportedArchitectures + guard supportedArchitectures.contains(SystemInfo.architecture) else { + let supported = supportedArchitectures.map(\.rawValue).joined(separator: ", ") + throw UpdateError.incompatibleSystem("Update requires \(supported) architecture") + } + log.debug("Supported architectures check passed") + + log.success("All system requirement checks passed") + } +} diff --git a/Loop/Updater/UpdateDownloader.swift b/Loop/Updater/UpdateDownloader.swift new file mode 100644 index 00000000..2bd8c956 --- /dev/null +++ b/Loop/Updater/UpdateDownloader.swift @@ -0,0 +1,366 @@ +// +// UpdateDownloader.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import AppKit +import Foundation +import Scribe + +@Loggable +final class UpdateDownloader: NSObject { + // MARK: - Properties + + private var urlSession: URLSession? + private var downloadTask: URLSessionDownloadTask? + private var progressClosure: ((UpdateProgress) async -> ())? + private var completionClosure: ((Result) -> ())? + private(set) var isDownloading = false + private var performanceTracker: PerformanceTracker = .init() + + deinit { + downloadTask?.cancel() + downloadTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + } + + // MARK: - Public Interface + + func downloadUpdate( + manifest: UpdateManifest, + progress: @escaping (UpdateProgress) async -> () + ) async throws -> URL { + guard !isDownloading else { + throw DownloadError.downloadInProgress + } + + guard let downloadURL = URL(string: manifest.downloadUrl) else { + throw DownloadError.invalidURL(manifest.downloadUrl) + } + + log.info("Starting download - URL: \(manifest.downloadUrl), Version: \(manifest.version)") + + try FileManager.default.createDirectory( + at: SystemPaths.loopDirectory, + withIntermediateDirectories: true + ) + + return try await withCheckedThrowingContinuation { continuation in + setupDownload(url: downloadURL, progress: progress) { result in + switch result { + case let .success(success): + continuation.resume(returning: success) + case let .failure(failure): + continuation.resume(throwing: failure) + } + } + } + } + + func cancel() { + log.info("Cancelling download") + isDownloading = false + downloadTask?.cancel() + + Task { + await cleanup() + } + } + + // MARK: - Private Implementation + + private func setupDownload( + url: URL, + progress: @escaping (UpdateProgress) async -> (), + completion: @escaping (Result) -> () + ) { + isDownloading = true + progressClosure = progress + completionClosure = completion + performanceTracker.reset() + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 30.0 * 2 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.httpMaximumConnectionsPerHost = 1 + + urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) + downloadTask = urlSession?.downloadTask(with: url) + downloadTask?.resume() + } + + private nonisolated func handleDownloadCompletion(at location: URL, originalURL: URL) { + log.info("Download completed - Temp Location: \(location.path)") + + var finalURL: URL + + do { + finalURL = try FileOperations.moveDownloadedFile( + from: location, + originalURL: originalURL, + to: SystemPaths.loopDirectory + ) + try FileValidator.validateDownloadedFile(at: finalURL) + } catch { + handleError(error) + return + } + + // Now that the file has been moved synchronously, we can launch a task to complete the update. + + Task { + do { + try await handleCompletion(with: finalURL) + } catch { + handleError(error) + } + } + } + + private func handleCompletion(with url: URL) async throws { + completionClosure?(.success(url)) + await cleanup() + } + + private func handleError(_ error: Error) { + completionClosure?(.failure(error)) + Task { await cleanup() } + } + + @MainActor + private func cleanup() async { + isDownloading = false + downloadTask?.cancel() + downloadTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + progressClosure = nil + completionClosure = nil + performanceTracker.reset() + } +} + +// MARK: URLSessionDownloadDelegate + +extension UpdateDownloader: URLSessionDownloadDelegate { + nonisolated func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + if let httpResponse = downloadTask.response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + handleError(DownloadError.networkError(.init(URLError.Code(rawValue: httpResponse.statusCode)))) + return + } + + guard let originalURL = downloadTask.originalRequest?.url else { + handleError(DownloadError.missingOriginalURL) + return + } + + handleDownloadCompletion(at: location, originalURL: originalURL) + } + + nonisolated func urlSession( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + Task { + guard self.isDownloading else { return } + + let progress = self.performanceTracker.updateProgress( + bytesWritten: bytesWritten, + totalBytesWritten: totalBytesWritten, + totalBytesExpectedToWrite: totalBytesExpectedToWrite + ) + + await self.progressClosure?(progress) + } + } + + nonisolated func urlSession( + _: URLSession, + task _: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let error else { return } + + Task { + guard self.isDownloading else { return } + + log.error("Download failed: \(error.localizedDescription)") + + let downloadError: DownloadError = (error as? URLError).map(DownloadError.networkError) ?? .unknown(error) + self.handleError(downloadError) + } + } +} + +// MARK: - PerformanceTracker + +private struct PerformanceTracker { + private var lastProgressUpdate: Date? + private var speedSamples: CircularBuffer = .init(capacity: 5) + + mutating func reset() { + lastProgressUpdate = Date() + speedSamples.clear() + } + + mutating func updateProgress( + bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) -> UpdateProgress { + let now = Date() + let percentage = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) + + // Update speed calculation + if let lastUpdate = lastProgressUpdate { + let timeDelta = now.timeIntervalSince(lastUpdate) + if timeDelta > 0.5 { // Update every 500ms + let speed = Double(bytesWritten) / timeDelta / 1_048_576 // MB/s + speedSamples.append(speed) + lastProgressUpdate = now + } + } else { + lastProgressUpdate = now + } + + let downloadSpeed = calculateAverageSpeed() + let estimatedTimeRemaining = calculateETA(speed: downloadSpeed, remainingBytes: totalBytesExpectedToWrite - totalBytesWritten) + + return UpdateProgress( + phase: .downloading, + percentage: percentage, + bytesDownloaded: totalBytesWritten, + totalBytes: totalBytesExpectedToWrite, + estimatedTimeRemaining: estimatedTimeRemaining, + downloadSpeed: downloadSpeed + ) + } + + private func calculateAverageSpeed() -> Double? { + let samples = speedSamples.elements + guard !samples.isEmpty else { return nil } + return samples.reduce(0, +) / Double(samples.count) + } + + private func calculateETA(speed: Double?, remainingBytes: Int64) -> TimeInterval? { + guard let speed, speed > 0 else { return nil } + return Double(remainingBytes) / (speed * 1_048_576) + } +} + +// MARK: - FileOperations + +@Loggable(style: .static) +private enum FileOperations { + static func moveDownloadedFile(from tempLocation: URL, originalURL: URL, to loopDir: URL) throws -> URL { + guard FileManager.default.fileExists(atPath: tempLocation.path) else { + log.error("Downloaded file does not exist at \(tempLocation.path)") + throw DownloadError.fileValidationFailed("File doesn't exist at temporary download directory") + } + + // Preserve original filename instead of renaming to "LoopUpdate.zip" + let originalFilename = originalURL.lastPathComponent + let finalURL = loopDir.appendingPathComponent(originalFilename) + let tempFinalURL = loopDir.appendingPathComponent("\(originalFilename).tmp") + + log.info("Moving downloaded file - From: \(tempLocation.path), To: \(finalURL.path), Original: \(originalURL.absoluteString)") + + // Move to Application Support/Loop/Loop.zip.tmp + try? FileManager.default.removeItem(at: tempFinalURL) + try FileManager.default.moveItem(at: tempLocation, to: tempFinalURL) + + // Rename to to Application Support/Loop/Loop.zip + try? FileManager.default.removeItem(at: finalURL) + try FileManager.default.moveItem(at: tempFinalURL, to: finalURL) + + log.info("File moved successfully - Final Location: \(finalURL.path), Filename: \(finalURL.lastPathComponent)") + + return finalURL + } +} + +// MARK: - FileValidator + +private enum FileValidator { + static func validateDownloadedFile(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { + throw DownloadError.fileValidationFailed("Downloaded file does not exist") + } + + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = attributes[.size] as? Int64, fileSize > 0 else { + throw DownloadError.fileValidationFailed("Downloaded file is empty") + } + } +} + +// MARK: - DownloadError + +enum DownloadError: LocalizedError, Sendable { + case downloadInProgress + case invalidURL(String) + case environmentError(String) + case insufficientDiskSpace(available: Int64, required: Int64) + case fileValidationFailed(String) + case networkError(URLError) + case missingOriginalURL + case unknown(Error) + + var errorDescription: String? { + switch self { + case .downloadInProgress: + return "A download is already in progress" + case let .invalidURL(url): + return "Invalid download URL: \(url)" + case let .environmentError(message): + return "Environment error: \(message)" + case let .insufficientDiskSpace(available, required): + let availableMB = available / 1_048_576 + let requiredMB = required / 1_048_576 + return "Insufficient disk space: \(availableMB)MB available, \(requiredMB)MB required" + case let .fileValidationFailed(reason): + return "File validation failed: \(reason)" + case let .networkError(urlError): + return "Network error: \(urlError.localizedDescription)" + case .missingOriginalURL: + return "Download task is missing its original URL" + case let .unknown(error): + return "Unknown error: \(error.localizedDescription)" + } + } +} + +// MARK: - CircularBuffer + +private struct CircularBuffer { + private var buffer: [T] = [] + private let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + } + + var elements: [T] { buffer } + + mutating func append(_ element: T) { + buffer.append(element) + if buffer.count > capacity { + buffer.removeFirst() + } + } + + mutating func clear() { + buffer.removeAll() + } +} diff --git a/Loop/Updater/UpdateInstaller.swift b/Loop/Updater/UpdateInstaller.swift new file mode 100644 index 00000000..f929ee40 --- /dev/null +++ b/Loop/Updater/UpdateInstaller.swift @@ -0,0 +1,811 @@ +// +// UpdateInstaller.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import AppKit +import Foundation +import Scribe + +@Loggable +actor UpdateInstaller { + // MARK: - Properties + + private let backupManager: BackupManager + private let fileManager: FileManager + + private var isCancelled = false + private var relocateToApplications = false + private var installedAppURL: URL = Bundle.main.bundleURL + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + self.backupManager = BackupManager(fileManager: fileManager) + } + + func installUpdate( + from downloadURL: URL, + manifest: UpdateManifest, + progress: @escaping (UpdateProgress) async -> () + ) async throws { + log.info("Starting installation of update: \(manifest.version)") + + // Step 1: Pre-installation verification + try await performPreInstallationChecks(manifest: manifest) + await progress(UpdateProgress(phase: .checking, percentage: 1.0 / 8.0)) + + // Step 2: Verify download integrity + try await verifyDownloadIntegrity(downloadURL, manifest: manifest) + await progress(UpdateProgress(phase: .downloading, percentage: 2.0 / 8.0)) + + // Step 3: Extract and verify + let extractedURL = try await extract(downloadURL) + await progress(UpdateProgress(phase: .extracting, percentage: 3.0 / 8.0)) + + // Step 4: Verify extraction integrity + try await verifyExtractionIntegrity(extractedURL, manifest: manifest) + await progress(UpdateProgress(phase: .verifying, percentage: 4.0 / 8.0)) + + // Step 5: Perform safe installation + try await performSafeInstallation(from: extractedURL, manifest: manifest) + await progress(UpdateProgress(phase: .installing, percentage: 5.0 / 8.0)) + + // Step 6: Comprehensive verification + try await performFinalVerification(manifest: manifest) + await progress(UpdateProgress(phase: .verifying, percentage: 6.0 / 8.0)) + + // Step 7: Cleanup + try await performSafeCleanup(extractedURL, downloadURL) + await progress(UpdateProgress(phase: .cleaning, percentage: 7.0 / 8.0)) + + try performPreRestartSafetyChecks() + await progress(UpdateProgress(phase: .verifying, percentage: 8.0 / 8.0)) + + log.success("Installation completed successfully") + } + + func restartApplication() async { + log.info("Preparing application restart from: \(installedAppURL.path)") + + // Verify the app exists before attempting restart + guard fileManager.fileExists(atPath: installedAppURL.path) else { + log.error("Application not found at path before restart: \(installedAppURL.path)") + return + } + + log.notice("Application will now restart. New instance will launch in 0.5 seconds.") + + let appURL = installedAppURL + let process = Process() + process.launchPath = "/bin/sh" + process.arguments = ["-c", "sleep 0.5; open \"\(appURL.path)\""] + process.launch() + + await MainActor.run { + NSApp.terminate(nil) + } + } + + func cancel() async { + log.warn("Cancelling installation") + isCancelled = true + } + + // MARK: - Pre-Installation Safety Checks + + private func performPreInstallationChecks(manifest: UpdateManifest) async throws { + log.info("Performing pre-installation safety checks") + + try checkCancellation() + + let checks: [(String, () async throws -> ())] = [ + ("disk space", { try await self.verifyDiskSpace(manifest: manifest) }), + ("current app integrity", { try await self.verifyCurrentAppIntegrity() }), + ("installation permissions", { try await self.verifyInstallationPermissions() }), + ("conflicting processes", { try await self.checkForConflictingRunningProcesses() }), + ("app location", { try await self.checkAppLocationAndOfferRelocation() }) + ] + + for (checkName, check) in checks { + do { + try await check() + log.debug("\(checkName) check passed") + } catch { + log.error("\(checkName) check failed: \(error)") + throw error + } + } + + log.success("All pre-installation safety checks passed") + } + + private func checkAppLocationAndOfferRelocation() async throws { + let location = AppLocation.current + + switch location { + case .systemApplications, .userApplications: + log.info("App is in Applications folder: \(location)") + relocateToApplications = false + case let .other(path): + log.warn("App is not in Applications folder: \(path)") + + let shouldRelocate = await askUserForRelocation() + + if shouldRelocate { + log.info("User chose to install to Applications folder") + relocateToApplications = true + } else { + log.info("User chose to keep current location. Update will install to: \(path)") + relocateToApplications = false + } + } + } + + @MainActor + private func askUserForRelocation() async -> Bool { + let alert = NSAlert() + alert.messageText = String(localized: "Move to Applications Folder?") + alert.informativeText = String(localized: "\(Bundle.main.appName) is not in your Applications folder. Would you like to install the update to your Applications folder instead?") + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Install to Applications")) + alert.addButton(withTitle: String(localized: "Keep in Current Location")) + return alert.runModal() == .alertFirstButtonReturn + } + + private func verifyDiskSpace(manifest _: UpdateManifest) async throws { + log.info("Verifying disk space requirements") + + let currentAppSize = try calculateAppSize(Bundle.main.bundleURL) + let requiredSpace = currentAppSize * 3 // Current app + backup + new app + + let availableSpace = try getAvailableDiskSpace() + + guard availableSpace > requiredSpace else { + let errorMessage = + "Insufficient disk space. Required: \(requiredSpace.formattedBytes), Available: \(availableSpace.formattedBytes)" + log.error("\(errorMessage)") + throw UpdateError.installationFailed(errorMessage) + } + + log.success("Disk space verification passed. Available: \(availableSpace.formattedBytes), Required: \(requiredSpace.formattedBytes)") + } + + private func verifyCurrentAppIntegrity() async throws { + try validateAppBundle(Bundle.main.bundleURL, skipVersionCheck: true) + log.success("Current application integrity verified") + } + + private func verifyInstallationPermissions() async throws { + log.info("Verifying installation permissions") + + let currentAppURL = Bundle.main.bundleURL + let parentDirectory = currentAppURL.deletingLastPathComponent() + + // Check write permissions to parent directory + guard fileManager.isWritableFile(atPath: parentDirectory.path) else { + throw UpdateError.installationFailed("No write permissions to application directory: \(parentDirectory.path)") + } + + // Test by creating a temporary file + let testFile = parentDirectory.appendingPathComponent("loop_permission_test_\(UUID().uuidString)") + + do { + try "test".write(to: testFile, atomically: true, encoding: .utf8) + try fileManager.removeItem(at: testFile) + } catch { + throw UpdateError.installationFailed("Cannot write to application directory: \(error.localizedDescription)") + } + + log.success("Installation permissions verified") + } + + private func checkForConflictingRunningProcesses() async throws { + log.info("Checking for interfering processes") + + // Check if any other updater processes are running + let runningApps = NSWorkspace.shared.runningApplications + let interferingApps = runningApps.filter { app in + guard let bundleId = app.bundleIdentifier else { return false } + return bundleId.contains("updater") || bundleId.contains("installer") + } + + if !interferingApps.isEmpty { + let appNames = interferingApps.compactMap(\.localizedName).joined(separator: ", ") + log.warn("Found potentially interfering processes: \(appNames)") + } + + log.success("Process interference check completed") + } + + // MARK: - Download Verification + + private func verifyDownloadIntegrity(_ downloadURL: URL, manifest: UpdateManifest) async throws { + try checkCancellation() + log.info("Performing comprehensive download verification") + + // Basic file existence and readability + guard fileManager.fileExists(atPath: downloadURL.path) else { + throw UpdateError.installationFailed("Download file does not exist: \(downloadURL.path)") + } + + guard fileManager.isReadableFile(atPath: downloadURL.path) else { + throw UpdateError.installationFailed("Download file is not readable: \(downloadURL.path)") + } + + // File size verification + let attributes = try fileManager.attributesOfItem(atPath: downloadURL.path) + let fileSize = attributes[.size] as? Int64 ?? 0 + + guard fileSize > 0 else { + throw UpdateError.installationFailed("Download file is empty") + } + + // Minimum reasonable size check (1KB) + guard fileSize > 1024 else { + throw UpdateError.installationFailed("Download file is suspiciously small: \(fileSize) bytes") + } + + log.info("Download file size: \(fileSize.formattedBytes)") + + // Checksum verification + try await ChecksumVerifier.verifyFile( + downloadURL, + expectedChecksum: manifest.checksums.zip + ) + + log.success("Download integrity verification completed") + } + + // MARK: - Extraction + + private func extract(_ downloadURL: URL) async throws -> URL { + try checkCancellation() + return try ZipExtractor.extract(from: downloadURL, cancellationCheck: checkCancellation) + } + + // MARK: - Extraction Integrity Verification + + private func verifyExtractionIntegrity(_ extractedURL: URL, manifest: UpdateManifest) async throws { + try checkCancellation() + log.info("Performing extraction integrity verification") + + // Find and verify app bundle + let appBundle = try BundleUtilities.findAppBundle(in: extractedURL) + + // Comprehensive bundle validation + try validateAppBundle(appBundle, manifest: manifest) + + // Code signature validation + try await validateAppCodeSignature(appBundle) + + log.success("Extraction integrity verification completed") + } + + private func validateAppBundle(_ appBundle: URL, skipVersionCheck: Bool = false, manifest: UpdateManifest? = nil) throws { + log.info("Validating app bundle: \(appBundle.lastPathComponent)") + + // Check bundle structure + try BundleUtilities.verifyBundleStructure(appBundle) + + // Check Info.plist + let infoPlistURL = appBundle.appendingPathComponent("Contents/Info.plist") + guard let plist = NSDictionary(contentsOf: infoPlistURL) else { + throw UpdateError.installationFailed("Could not read Info.plist") + } + + // Validate basic bundle properties + guard let bundleIdentifier = plist["CFBundleIdentifier"] as? String, !bundleIdentifier.isEmpty else { + throw UpdateError.installationFailed("Invalid CFBundleIdentifier") + } + + guard let packageType = plist["CFBundlePackageType"] as? String, packageType == "APPL" else { + throw UpdateError.installationFailed("Invalid CFBundlePackageType") + } + + // Validate executable + guard let executableName = plist["CFBundleExecutable"] as? String, !executableName.isEmpty else { + throw UpdateError.installationFailed("Missing CFBundleExecutable") + } + + let executablePath = appBundle.appendingPathComponent("Contents/MacOS/\(executableName)") + guard fileManager.fileExists(atPath: executablePath.path) else { + throw UpdateError.installationFailed("Executable not found: \(executableName)") + } + + let executableAttributes = try fileManager.attributesOfItem(atPath: executablePath.path) + guard let permissions = executableAttributes[.posixPermissions] as? NSNumber, + permissions.intValue & 0o111 != 0 else { + throw UpdateError.installationFailed("Executable lacks execute permissions") + } + + // Version validation for extracted apps + if !skipVersionCheck, let manifest { + try BundleUtilities.verifyVersionMatches(bundleURL: appBundle, manifest: manifest) + } + + // System compatibility check + try validateSystemCompatibility(plist) + + log.success("App bundle validation completed") + } + + private func validateSystemCompatibility(_ plist: NSDictionary) throws { + // Check minimum OS version from plist + if let minOSString = plist["LSMinimumSystemVersion"] as? String { + let components = minOSString.split(separator: ".").compactMap { Int($0) } + if components.count >= 2 { + let minOSVersion = OperatingSystemVersion( + majorVersion: components[0], + minorVersion: components[1], + patchVersion: components.count > 2 ? components[2] : 0 + ) + + guard ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) else { + throw UpdateError.installationFailed("App manifest inconsistency: app actually requires macOS \(minOSString) or later.") + } + } + } + + // Check supported architectures – plist value is an array of strings + if let archStrings = plist["LSArchitecturePriority"] as? [String] { + // Map string representations to our internal Architecture enum + let supportedArchitectures: [SystemInfo.Architecture] = archStrings.compactMap { arch in + switch arch.lowercased() { + case "arm64": .arm64 + case "x86_64", "x86-64", "x86": .x86_64 + default: nil + } + } + + guard !supportedArchitectures.isEmpty else { + // No recognized architectures, assume compatible + return + } + + guard supportedArchitectures.contains(SystemInfo.architecture) else { + throw UpdateError.installationFailed("App does not support current architecture") + } + } + } + + private func validateAppCodeSignature(_ appBundle: URL) async throws { + log.info("Validating app code signature") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--verify", "--verbose", appBundle.path] + + let errorPipe = Pipe() + process.standardError = errorPipe + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: errorData, encoding: .utf8) ?? "Unknown codesign error" + throw UpdateError.installationFailed("Code signature validation failed: \(errorOutput)") + } + + log.success("Code signature validation passed") + } + + // MARK: - Safe Installation + + private func performSafeInstallation(from extractedURL: URL, manifest: UpdateManifest) async throws { + try checkCancellation() + log.info("Performing safe installation") + + let appBundle = try BundleUtilities.findAppBundle(in: extractedURL) + + if relocateToApplications { + try await performRelocationInstall(from: appBundle, manifest: manifest) + } else { + // Pre-installation verification (checks current running app) + try await verifyPreInstallationState() + + // Perform atomic installation to current location + let currentAppURL = Bundle.main.bundleURL + try await performAtomicInstallation(from: appBundle, to: currentAppURL, manifest: manifest) + } + + // Post-installation verification + try await verifyPostInstallationState(manifest: manifest) + + log.success("Safe installation completed") + } + + private func performRelocationInstall(from appBundle: URL, manifest: UpdateManifest) async throws { + log.info("Installing to Applications folder") + + let userAppsURL = fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications") + let destinationURL = userAppsURL.appendingPathComponent("Loop.app") + + // Create ~/Applications if needed + try fileManager.createDirectory(at: userAppsURL, withIntermediateDirectories: true) + + // Remove existing app at destination if present + if fileManager.fileExists(atPath: destinationURL.path) { + log.info("Removing existing app at destination: \(destinationURL.path)") + try fileManager.removeItem(at: destinationURL) + } + + // Copy new app to Applications + log.info("Copying new version to: \(destinationURL.path)") + try fileManager.copyItem(at: appBundle, to: destinationURL) + + // Verify the installation + try BundleUtilities.verifyBundleStructure(destinationURL) + try BundleUtilities.verifyVersionMatches(bundleURL: destinationURL, manifest: manifest) + + // Store the new location for restart + installedAppURL = destinationURL + + // Remove old app from original location + let oldAppURL = Bundle.main.bundleURL + log.info("Removing old app from: \(oldAppURL.path)") + do { + try fileManager.removeItem(at: oldAppURL) + } catch { + log.warn("Could not remove old app: \(error.localizedDescription)") + } + + log.success("Successfully installed to Applications folder") + } + + private func verifyPreInstallationState() async throws { + log.info("Verifying pre-installation state") + + let currentAppURL = Bundle.main.bundleURL + guard fileManager.fileExists(atPath: currentAppURL.path) else { + throw UpdateError.installationFailed("Current application no longer exists before installation") + } + + try validateAppBundle(currentAppURL, skipVersionCheck: true) + log.success("Pre-installation state verified") + } + + private func verifyPostInstallationState(manifest: UpdateManifest) async throws { + log.info("Verifying post-installation state") + + guard fileManager.fileExists(atPath: installedAppURL.path) else { + throw UpdateError.installationFailed("Application missing after installation - CRITICAL ERROR") + } + + try validateAppBundle(installedAppURL, manifest: manifest) + log.success("Post-installation state verified") + } + + // MARK: - Atomic Installation + + private func performAtomicInstallation( + from sourceURL: URL, + to destinationURL: URL, + manifest: UpdateManifest + ) async throws { + log.info("Performing atomic installation") + + let stagingURL = destinationURL.appendingPathExtension("staging") + + do { + try await executeAtomicInstallationSteps( + source: sourceURL, + staging: stagingURL, + destination: destinationURL, + manifest: manifest + ) + log.info("Atomic installation completed successfully") + } catch { + await cleanupStaging(stagingURL) + throw error + } + } + + private func executeAtomicInstallationSteps( + source: URL, + staging: URL, + destination: URL, + manifest: UpdateManifest + ) async throws { + try copyToStaging(from: source, to: staging) + try await verifyStaged(staging, manifest: manifest) + try await atomicSwap(staged: staging, current: destination) + } + + private func copyToStaging(from sourceURL: URL, to stagingURL: URL) throws { + try checkCancellation() + + log.debug("Copying application to staging area") + + if fileManager.fileExists(atPath: stagingURL.path) { + try fileManager.removeItem(at: stagingURL) + } + try fileManager.copyItem(at: sourceURL, to: stagingURL) + } + + private func verifyStaged(_ stagingURL: URL, manifest: UpdateManifest) async throws { + try checkCancellation() + + log.debug("Verifying staged application") + + try BundleUtilities.verifyBundleStructure(stagingURL) + try BundleUtilities.verifyVersionMatches(bundleURL: stagingURL, manifest: manifest) + try await testStagedApplication(stagingURL) + } + + private func testStagedApplication(_ bundleURL: URL) async throws { + log.debug("Testing staged application") + + let executablePath = bundleURL.appendingPathComponent("Contents/MacOS") + let contents = try fileManager.contentsOfDirectory( + at: executablePath, + includingPropertiesForKeys: nil + ) + + guard !contents.isEmpty else { + log.error("No executable found in MacOS directory") + throw UpdateError.installationFailed("No executable found in app bundle") + } + + log.debug("Application testing passed") + } + + private func atomicSwap(staged stagingURL: URL, current currentURL: URL) async throws { + try checkCancellation() + + log.info("Starting atomic swap") + log.info("Current app: \(currentURL.path)") + log.info("Staged app: \(stagingURL.path)") + + try await backupManager.prepareForBackup() + let backupURL = try await backupManager.createBackupURL() + + try await performSwapOperation( + current: currentURL, + staged: stagingURL, + backup: backupURL + ) + } + + private func performSwapOperation(current: URL, staged: URL, backup: URL) async throws { + do { + log.info("Moving current app to backup...") + + // Ensure the backup directory exists + let backupParent = backup.deletingLastPathComponent() + try fileManager.createDirectory(at: backupParent, withIntermediateDirectories: true) + + // Check if backup already exists and remove it if necessary + if fileManager.fileExists(atPath: backup.path) { + log.warn("Backup already exists at \(backup.path), removing it first") + try fileManager.removeItem(at: backup) + } + + try fileManager.moveItem(at: current, to: backup) + log.info("Current app backed up to: \(backup.path)") + + log.info("Moving staged app to current location...") + try fileManager.moveItem(at: staged, to: current) + log.info("New app installed at: \(current.path)") + + // Verify the atomic swap was successful + try verifySwapSuccess(current: current, backup: backup, staged: staged) + log.success("Atomic swap completed and verified successfully!") + } catch { + log.error("Atomic swap failed: \(error)") + log.error("Current: \(current.path), Staged: \(staged.path), Backup: \(backup.path)") + log.error("Current exists: \(fileManager.fileExists(atPath: current.path))") + log.error("Staged exists: \(fileManager.fileExists(atPath: staged.path))") + log.error("Backup exists: \(fileManager.fileExists(atPath: backup.path))") + + try await backupManager.restoreFromBackup(currentURL: current, backupURL: backup) + throw error + } + } + + private func verifySwapSuccess(current: URL, backup: URL, staged: URL) throws { + log.debug("Verifying atomic swap success...") + + // 1. Verify backup was created successfully + guard fileManager.fileExists(atPath: backup.path) else { + throw UpdateError.installationFailed("Atomic swap verification failed: Backup not found at expected location: \(backup.path)") + } + + // Verify backup has correct bundle structure + try BundleUtilities.verifyBundleStructure(backup) + log.debug("Backup bundle structure verified") + + // Verify backup has a valid Info.plist and version + let backupInfoPlistURL = backup.appendingPathComponent("Contents/Info.plist") + guard fileManager.fileExists(atPath: backupInfoPlistURL.path) else { + throw UpdateError.installationFailed("Atomic swap verification failed: Backup app Info.plist not found") + } + + guard let backupPlist = NSDictionary(contentsOf: backupInfoPlistURL), + let backupVersion = backupPlist["CFBundleShortVersionString"] as? String, + !backupVersion.isEmpty else { + throw UpdateError.installationFailed("Atomic swap verification failed: Backup app version information is invalid") + } + + log.debug("Backup version verified: \(backupVersion)") + + // 2. Verify new app was installed successfully + guard fileManager.fileExists(atPath: current.path) else { + throw UpdateError.installationFailed("Atomic swap verification failed: New app not found at expected location: \(current.path)") + } + + // Verify new app has correct bundle structure + try BundleUtilities.verifyBundleStructure(current) + log.debug("New app bundle structure verified") + + // Verify new app has a valid Info.plist and version + let infoPlistURL = current.appendingPathComponent("Contents/Info.plist") + guard fileManager.fileExists(atPath: infoPlistURL.path) else { + throw UpdateError.installationFailed("Atomic swap verification failed: New app Info.plist not found") + } + + guard let plist = NSDictionary(contentsOf: infoPlistURL), + let version = plist["CFBundleShortVersionString"] as? String, + !version.isEmpty else { + throw UpdateError.installationFailed("Atomic swap verification failed: New app version information is invalid") + } + + log.debug("New app version verified: \(version)") + + // 3. Verify staging area is clean (should be empty after move) + if fileManager.fileExists(atPath: staged.path) { + log.warn("Staging area still exists (this is usually fine): \(staged.path)") + } + + // 4. Verify file sizes are reasonable (basic sanity check) + let backupAttributes = try fileManager.attributesOfItem(atPath: backup.path) + let currentAttributes = try fileManager.attributesOfItem(atPath: current.path) + + guard let backupSize = backupAttributes[.size] as? Int64, backupSize > 0 else { + throw UpdateError.installationFailed("Atomic swap verification failed: Backup appears to be empty or invalid") + } + + guard let currentSize = currentAttributes[.size] as? Int64, currentSize > 0 else { + throw UpdateError.installationFailed("Atomic swap verification failed: New app appears to be empty or invalid") + } + + log.debug("File sizes verified - Backup: \(backupSize.formattedBytes), New: \(currentSize.formattedBytes)") + log.debug("Atomic swap verification completed") + } + + private func cleanupStaging(_ stagingURL: URL) async { + try? fileManager.removeItem(at: stagingURL) + } + + // MARK: - Final Verification + + private func performFinalVerification(manifest: UpdateManifest) async throws { + try checkCancellation() + log.info("Performing comprehensive installation verification") + + // Verify app can be read + guard fileManager.isReadableFile(atPath: installedAppURL.path) else { + throw UpdateError.installationFailed("Installed application is not readable") + } + + // Comprehensive bundle validation + try validateAppBundle(installedAppURL, manifest: manifest) + + log.success("Comprehensive verification completed") + } + + // MARK: - Pre-Restart Verification + + private func performPreRestartSafetyChecks() throws { + log.info("Performing pre-restart verification") + + // Final check that app exists + guard fileManager.fileExists(atPath: installedAppURL.path) else { + throw UpdateError.installationFailed("Application missing before restart") + } + + // Final structure check + try BundleUtilities.verifyBundleStructure(installedAppURL) + + // Check executable exists and has permissions + let executablePath = try BundleUtilities.executablePath(for: installedAppURL) + guard fileManager.fileExists(atPath: executablePath.path) else { + throw UpdateError.installationFailed("Application executable missing before restart") + } + + let attributes = try fileManager.attributesOfItem(atPath: executablePath.path) + let permissions = attributes[.posixPermissions] as? NSNumber + guard let permissions, permissions.intValue & 0o111 != 0 else { + throw UpdateError.installationFailed("Application executable lacks execute permissions before restart") + } + + log.success("Pre-restart verification passed") + } + + // MARK: - Standard Methods + + private func performSafeCleanup(_ extractedURL: URL, _ downloadURL: URL) async throws { + log.info("Performing safe cleanup of temporary files") + + let cleanupOperations = [ + (extractedURL, "extraction directory"), + (downloadURL, "download file") + ] + + for (url, description) in cleanupOperations { + if fileManager.fileExists(atPath: url.path) { + do { + try fileManager.removeItem(at: url) + log.debug("Removed \(description): \(url.path)") + } catch { + log.warn("Failed to clean up \(description): \(error)") + // Don't fail installation for cleanup issues + } + } + } + + log.success("Safe cleanup completed") + } + + // MARK: - Utility Methods + + private func checkCancellation() throws { + guard !isCancelled else { + throw UpdateError.installationFailed("Installation cancelled") + } + } + + private func calculateAppSize(_ appURL: URL) throws -> Int64 { + var totalSize: Int64 = 0 + + let enumerator = fileManager.enumerator( + at: appURL, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) + + while let fileURL = enumerator?.nextObject() as? URL { + let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) + totalSize += Int64(resourceValues.fileSize ?? 0) + } + + return totalSize + } + + private func getAvailableDiskSpace() throws -> Int64 { + let attributes = try fileManager.attributesOfFileSystem(forPath: NSHomeDirectory()) + return attributes[.systemFreeSize] as? Int64 ?? 0 + } +} + +// MARK: - AppLocation + +enum AppLocation: CustomStringConvertible, Sendable { + case systemApplications + case userApplications + case other(String) + + static var current: AppLocation { + let bundlePath = Bundle.main.bundlePath + let userAppsPath = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path + let systemAppsPath = "/Applications" + + if bundlePath.hasPrefix(systemAppsPath) { + return .systemApplications + } else if bundlePath.hasPrefix(userAppsPath) { + return .userApplications + } else { + return .other(bundlePath) + } + } + + var description: String { + switch self { + case .systemApplications: "/Applications" + case .userApplications: "~/Applications" + case let .other(path): path + } + } +} diff --git a/Loop/Updater/Updater.swift b/Loop/Updater/Updater.swift index 4b9a66a0..b9ca34e4 100755 --- a/Loop/Updater/Updater.swift +++ b/Loop/Updater/Updater.swift @@ -10,6 +10,8 @@ import Luminare import Scribe import SwiftUI +@Loggable +@MainActor final class Updater: ObservableObject { static let shared = Updater() @@ -17,15 +19,17 @@ final class Updater: ObservableObject { didSet { updateStateChanged() } } - @Published private(set) var targetRelease: Release? + @Published private(set) var installState: InstallState = .ready @Published private(set) var progressBar: Double = 0 @Published private(set) var updatesEnabled: Bool = Updater.checkIfUpdatesEnabled() - @Published private(set) var changelog: [(title: String, body: [ChangelogNote])] = .init() - @Published var expandedChangelogSections: Set = [] // By title + @Published private(set) var changelog: [ChangelogSection] = [] + @Published var expandedChangelogSections: Set = [] // By ID + @Published private(set) var updateManifest: UpdateManifest? private(set) var shouldAutoPresentUpdateWindow: Bool = false private var windowController: NSWindowController? private var includeDevelopmentVersions: Bool { Defaults[.includeDevelopmentVersions] } + private var automaticallyUpdate: Bool { Defaults[.automaticallyUpdate] } private var updateFetcherTask: Task<(), Never>? private var updateCheckerTask: Task<(), Never>? @@ -33,39 +37,36 @@ final class Updater: ObservableObject { private var includeDevelopmentVersionsObserver: Task<(), Never>? private var updatesEnabledObserver: Task<(), Never>? - struct ChangelogNote: Identifiable { - var id: UUID = .init() - var emoji: String - var text: String - var user: String? - var reference: Int? - } + private let updateChecker: UpdateChecker + private let downloader: UpdateDownloader + private let installer: UpdateInstaller - enum UpdateAvailability { - case available - case unavailable - case osNotSupported - - var text: String { - switch self { - case .unavailable: - String(localized: "Check for updates…") - case .available: - String(localized: "Update…") - case .osNotSupported: - String(localized: "This macOS version is no longer supported.") - } + private init() { + // Initialize new updater system components + self.updateChecker = UpdateChecker() + self.downloader = UpdateDownloader() + self.installer = UpdateInstaller() + + // Initialize optional properties to nil - will be set up after init + self.updateCheckerTask = nil + self.includeDevelopmentVersionsObserver = nil + self.updatesEnabledObserver = nil + + // Set up observers and tasks after initialization is complete + Task { + setupObserversAndTasks() } } - private init() { - // Only set up the timer if updates are enabled and env var is not set + private func setupObserversAndTasks() { + // Set up observers and tasks now that self is fully initialized + let updatesEnabled = Self.checkIfUpdatesEnabled() if updatesEnabled { - self.updateCheckerTask = makeUpdateCheckerTask() - self.includeDevelopmentVersionsObserver = makeIncludeDevelopmentVersionsObserver() + updateCheckerTask = makeUpdateCheckerTask() + includeDevelopmentVersionsObserver = makeIncludeDevelopmentVersionsObserver() } - self.updatesEnabledObserver = makeUpdatesEnabledObserver() + updatesEnabledObserver = makeUpdatesEnabledObserver() } private static func checkIfUpdatesEnabled() -> Bool { @@ -81,11 +82,26 @@ final class Updater: ObservableObject { autoPresentUpdateWindowTask = nil if updateState == .available { + // If automatic updates are enabled, never auto-present the update window + if automaticallyUpdate { + // Only install if Loop is not in use + if !NSApp.isActive, NSApp.windows.allSatisfy({ !$0.isVisible }) { + log.info("Automatic updates enabled, installing update...") + Task { + try await downloadAndInstallUpdate() + await relaunchAfterUpdate() + } + } + + log.info("Automatic updates enabled, but Loop is active. Skipping installation.") + return + } + shouldAutoPresentUpdateWindow = true /// If the updater has requested that the update window be presented for over 6 hours, automatically present it. autoPresentUpdateWindowTask = Task { - Log.info("Will automatically present update window in 6 hours if there is no activity", category: .updater) + log.info("Will automatically present update window in 6 hours if there is no activity") try? await Task.sleep(for: .seconds(21600)) @@ -125,11 +141,9 @@ final class Updater: ObservableObject { for await _ in Defaults.updates(.updatesEnabled) { guard !Task.isCancelled else { break } - await MainActor.run { - updatesEnabled = Updater.checkIfUpdatesEnabled() - } + updatesEnabled = Updater.checkIfUpdatesEnabled() - Log.info("Updates enabled status changed to: \(updatesEnabled)", category: .updater) + log.info("Updates enabled status changed to: \(updatesEnabled)") if updatesEnabled { self.updateCheckerTask = makeUpdateCheckerTask() @@ -140,380 +154,152 @@ final class Updater: ObservableObject { self.updateCheckerTask = nil self.includeDevelopmentVersionsObserver = nil - await MainActor.run { - targetRelease = nil - updateState = .unavailable - progressBar = 0 - } + updateManifest = nil + updateState = .unavailable + progressBar = 0 } } } } - @MainActor func dismissWindow() { windowController?.close() windowController = nil + + // Clear update state when window is dismissed + updateManifest = nil + progressBar = 0 + installState = .ready + shouldAutoPresentUpdateWindow = false } // Pulls the latest release information from GitHub and updates the app state accordingly. - func fetchLatestInfo(force: Bool = false) async { + func fetchLatestInfo(bypassUpdatesEnabled: Bool = false) async { + // Don't run update checks while actively downloading + if downloader.isDownloading == true { + return + } + if let updateFetcherTask { - return await updateFetcherTask.value // If already fetching, wait for it to finish + await updateFetcherTask.value // If already fetching, wait for it to finish + return } updateFetcherTask = Task { defer { updateFetcherTask = nil } - await MainActor.run { - targetRelease = nil + // Don't clear update state if window is currently showing (user is interacting) + if windowController?.window?.isVisible != true { + updateManifest = nil progressBar = 0 } // Early return if updates are disabled and not forcing - guard updatesEnabled || force else { - await MainActor.run { - updateState = .unavailable - } + guard updatesEnabled || bypassUpdatesEnabled else { + updateState = .unavailable + log.warn("Updates are disabled. Not fetching latest info.") return } - Log.info("Fetching latest release info...", category: .updater) - - let urlString = includeDevelopmentVersions ? - "https://api.github.com/repos/MrKai77/Loop/releases" : // Developmental branch - "https://api.github.com/repos/MrKai77/Loop/releases/latest" // Stable branch - - guard let url = URL(string: urlString) else { - Log.error("Invalid URL: \(urlString)", category: .updater) - return - } + log.info("Fetching latest release info...") do { - let (data, _) = try await URLSession.shared.data(from: url) - - // Process data immediately after fetching, reducing the number of async suspension points. - try await processFetchedData(data) - } catch { - await MainActor.run { - updateState = .unavailable - } - Log.error("Error fetching release info: \(error.localizedDescription)", category: .updater) - } - } - - if let task = updateFetcherTask { - return await task.value - } - } - - private func processFetchedData(_ data: Data) async throws { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - if includeDevelopmentVersions { - // This would need to parse a list of releases - let releases = try decoder.decode([Release].self, from: data) - - if let latestPreRelease = releases.compactMap({ $0.prerelease ? $0 : nil }).first { - await processRelease(latestPreRelease) - } - } else { - // This would need to parse a single release - let release = try decoder.decode(Release.self, from: data) - await processRelease(release) - } - } - - private func processRelease(_ release: Release) async { - let currentVersion = Bundle.main.appVersion?.filter(\.isASCII).trimmingCharacters(in: .whitespaces) ?? "0.0.0" - - await MainActor.run { - var release = release - - if release.prerelease, - let versionDetails = release.extractPrereleaseVersionFromTitle() { - release.tagName = versionDetails.preRelease - release.buildNumber = versionDetails.buildNumber - } - - var newUpdateState: UpdateAvailability = release.tagName.compare(currentVersion, options: .numeric) == .orderedDescending ? .available : .unavailable - - // If the development version is chosen, compare the build number - if newUpdateState != .available, - includeDevelopmentVersions, - let versionBuild = release.buildNumber, - let currentBuild = Bundle.main.appBuild { - newUpdateState = versionBuild > currentBuild ? .available : .unavailable - } - - // If the update's tag and build number passes the checks above, check the minimum macOS version - if newUpdateState == .available { - let lines = release.body - .split(whereSeparator: \.isNewline) - .reversed() - - for line in lines { - if let minimumMacOSVersion = extractMinimumMacOSVersion(from: String(line)) { - if !ProcessInfo.processInfo.isOperatingSystemAtLeast(minimumMacOSVersion) { - Log.warn("Minimum macOS version requirement for next update not met (required: \(minimumMacOSVersion))", category: .updater) - newUpdateState = .osNotSupported - } else { - Log.success("Minimum macOS version requirement for next update is met", category: .updater) - } + // Use GitHub releases API + let channel: UpdateChannel = includeDevelopmentVersions ? .development : .stable + + let currentVersion = Bundle.main.appVersion?.filter(\.isASCII) + .trimmingCharacters(in: .whitespaces) ?? "0.0.0" + let currentBuild = Bundle.main.appBuild ?? 0 + + if let manifest = try await updateChecker.checkForUpdate( + currentVersion: currentVersion, + currentBuild: currentBuild, + channel: channel + ) { + changelog = ChangelogParser.parse(manifest.releaseNotes.body) + if let firstSection = changelog.first { + expandedChangelogSections = [firstSection.id] } - } - } - - updateState = newUpdateState - - if newUpdateState == .available { - Log.notice("Update available: \(release.name)", category: .updater) - - targetRelease = release - processChangelog(release.body) - } else { - Log.info("No update available.", category: .updater) - } - } - } - - func extractMinimumMacOSVersion(from changelog: String) -> OperatingSystemVersion? { - let regex = /Minimum macOS version:\s*(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?/ - - guard let match = changelog.firstMatch(of: regex.ignoresCase()), - let major = Int(match.major) - else { - return nil - } - - let minor = match.minor.flatMap { Int($0) } ?? 0 - let patch = match.patch.flatMap { Int($0) } ?? 0 - - return OperatingSystemVersion( - majorVersion: major, - minorVersion: minor, - patchVersion: patch - ) - } - - private func processChangelog(_ body: String) { - changelog = .init() - - let lines = body - .split(whereSeparator: \.isNewline) - - var currentSection: String? - - for line in lines where !line.isEmpty { - if line.starts(with: "#") { - currentSection = line - .replacing(/#/, with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - - if changelog.first(where: { $0.title == currentSection }) == nil { - changelog.append((title: currentSection!, body: [])) - } - } else { - guard - line.hasPrefix("- "), - let index = changelog.firstIndex(where: { $0.title == currentSection }) - else { - continue - } - let cleanedLine = line - .replacing(#/- /#, with: "") - .trimmingCharacters(in: .whitespaces) + updateManifest = manifest + updateState = .available - let user: String? - let reference: Int? - - if let match = cleanedLine.firstMatch(of: /(@(?\w+))/) { - user = String(match.user) + log.notice("Update available: \(manifest.version) build \(manifest.buildNumber)") } else { - user = nil - } + updateState = .unavailable - if let match = cleanedLine.firstMatch(of: /#(?\d+)/) { - reference = Int(String(match.reference)) + log.info("No updates available") + } + } catch { + if case .incompatibleSystem? = error as? UpdateError { + updateState = .osNotSupported } else { - reference = nil + updateState = .unavailable } - /// Use `isEmojiPresentation` instead of `isEmoji` to ensure that `#`s are excluded. - let emoji = cleanedLine.unicodeScalars.first(where: \.properties.isEmojiPresentation) ?? currentSection?.unicodeScalars.first(where: \.properties.isEmojiPresentation) ?? "🔄" - - let text = cleanedLine - .drop(while: { $0.unicodeScalars.first?.properties.isEmojiPresentation == true }) // Emojis - .replacing(#/#\d+/#, with: "") // Issue # - .replacing(#/(@.*?)/#, with: "") // Mentions - .trimmingCharacters(in: .whitespacesAndNewlines) - - changelog[index].body.append(.init( - emoji: String(emoji), - text: text, - user: user, - reference: reference - )) + log.error("Error fetching release info: \(error.localizedDescription)") } } - if let firstSection = changelog.first { - expandedChangelogSections = [firstSection.title] - } + await updateFetcherTask?.value } func showUpdateWindowIfEligible() async { shouldAutoPresentUpdateWindow = false guard updateState == .available else { return } - await MainActor.run { - if windowController?.window == nil { - windowController = .init(window: LuminareTrafficLightedWindow { UpdateView() }) - } - windowController?.window?.makeKeyAndOrderFront(self) - windowController?.window?.orderFrontRegardless() + if windowController?.window == nil { + windowController = .init(window: LuminareTrafficLightedWindow { UpdateView() }) } + windowController?.window?.makeKeyAndOrderFront(self) + windowController?.window?.orderFrontRegardless() - Log.ui("Update window shown", category: .updater) + log.ui("Update window shown") } // Downloads the update from GitHub and installs it - func installUpdate() async { - guard - let latestRelease = targetRelease, - let asset = latestRelease.assets.first - else { - await MainActor.run { - self.progressBar = 0 - } + func downloadAndInstallUpdate() async throws { + guard let manifest = updateManifest else { + progressBar = 0 return } - Log.info("Installing update: \(latestRelease.name)", category: .updater) - - let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("\(asset.name)_\(latestRelease.tagName)") - - await MainActor.run { - self.progressBar = 0.25 - } - - if !FileManager.default.fileExists(atPath: tempUrl.path) { - await downloadUpdate(asset, to: tempUrl) - } - - await MainActor.run { - self.progressBar = 0.75 - } - - await unzipAndSwap(downloadedFileURL: tempUrl.path) - - try? FileManager.default.removeItem(at: tempUrl) - - await MainActor.run { - self.progressBar = 1.0 - self.updateState = .unavailable - } - - Log.info("Update installed successfully", category: .updater) - } - - private func downloadUpdate(_ asset: Release.Asset, to destinationURL: URL) async { - Log.info("Downloading update asset: \(asset.name) to \(destinationURL.path)", category: .updater) - - do { - let (fileURL, _) = try await URLSession.shared.download(from: asset.browserDownloadURL) - try FileManager.default.moveItem(at: fileURL, to: destinationURL) - } catch { - Log.error("Failed to download update: \(error.localizedDescription)", category: .updater) - } - } - - private func unzipAndSwap(downloadedFileURL fileURL: String) async { - Log.info("Unzipping and swapping app bundle at \(fileURL)", category: .updater) + installState = .installing - let appBundle = Bundle.main.bundleURL - let fileManager = FileManager.default + log.info("Installing update: \(manifest.version)") do { - // Create a temporary directory - // It's ideal to keep this separate from the fileURL since this is where the swapping happens, and - // if this fails, it can't affect the original downloaded zip file. - let tempDir = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) - - // Unzip to a temp directory - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.arguments = ["-xk", fileURL, tempDir.path] - try process.run() - process.waitUntilExit() - - // Find the unzipped app bundle - let contents = try fileManager.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) - guard let newAppBundle = contents.first(where: { $0.pathExtension == "app" }) else { - Log.error("No app bundle found in extracted contents", category: .updater) - return + let downloadedFileURL = try await downloader.downloadUpdate(manifest: manifest) { [weak self] progress in + self?.progressBar = progress.percentage * 0.75 } - // Atomically swap the old app bundle with the new one - _ = try fileManager.replaceItemAt( - appBundle, - withItemAt: newAppBundle, - backupItemName: nil, - options: [.usingNewMetadataOnly] - ) - - // Clean up - try fileManager.removeItem(at: tempDir) - } catch { - Log.error("Error updating the app: \(error.localizedDescription)", category: .updater) - } - } -} - -// MARK: - Models - -// Release model to parse GitHub API response for releases. -struct Release: Codable { - var id: Int - var tagName: String - var name: String - var body: String - var assets: [Asset] - var prerelease: Bool - var creationDate: Date - var updateDate: Date + try await installer.installUpdate(from: downloadedFileURL, manifest: manifest) { [weak self] progress in + self?.progressBar = 0.75 + (progress.percentage * 0.25) + } - var buildNumber: Int? + progressBar = 1.0 + updateState = .unavailable - enum CodingKeys: String, CodingKey { - case id, tagName = "tag_name", name, body, assets, prerelease, creationDate = "created_at", updateDate = "updated_at" - } + // Brief delay before showing restart button + try? await Task.sleep(for: .seconds(1)) - struct Asset: Codable { - var name: String - var browserDownloadURL: URL + installState = .readyToRestart - enum CodingKeys: String, CodingKey { - case name - case browserDownloadURL = "browser_download_url" + log.success("Update installed successfully") + } catch { + log.error("Update installation failed: \(error)") + progressBar = 0 + installState = .failed(error) + throw error } } -} -// Extension to Release to extract version details from the title -extension Release { - func extractPrereleaseVersionFromTitle() -> (preRelease: String, buildNumber: Int)? { - let regex = /🧪 (?.*?) \((?\d+)\)/ - guard let match = name.firstMatch(of: regex) else { - return nil + func relaunchAfterUpdate() async { + guard installState == .readyToRestart else { + log.error("Cannot restart as the install state is \(installState)") + return } - let release = String(match.version) - let buildNumber = Int(String(match.build)) ?? 0 - - return (release, buildNumber) + await installer.restartApplication() } } diff --git a/Loop/Updater/UpdaterModels.swift b/Loop/Updater/UpdaterModels.swift new file mode 100644 index 00000000..8bd091ab --- /dev/null +++ b/Loop/Updater/UpdaterModels.swift @@ -0,0 +1,273 @@ +// +// UpdaterModels.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import Foundation + +// MARK: - UpdateChannel + +enum UpdateChannel: String, Sendable, CaseIterable { + case stable + case development + + var displayName: String { + switch self { + case .stable: "Stable" + case .development: "Development" + } + } + + var isDevelopmentChannel: Bool { + switch self { + case .stable: false + case .development: true + } + } + + var githubReleasesEndpoint: String { + switch self { + case .stable: "https://api.github.com/repos/MrKai77/Loop/releases/latest" + case .development: "https://api.github.com/repos/MrKai77/Loop/releases" + } + } +} + +// MARK: - UpdateManifest + +struct UpdateManifest: Sendable { + let version: String + let buildNumber: Int + let downloadUrl: String + let releaseNotes: ReleaseNotes + let checksums: Checksums + let compatibility: Compatibility + let channel: UpdateChannel + let publishedAt: Date + let size: Int64 + + struct ReleaseNotes: Sendable { + let title: String + let body: String + } + + struct Compatibility: Sendable { + let minimumOS: OperatingSystemVersion? + let maximumOS: OperatingSystemVersion? + let supportedArchitectures: [SystemInfo.Architecture] + } + + struct Checksums: Sendable { + let zip: String + } +} + +// MARK: - UpdateProgress + +struct UpdateProgress: Sendable { + let phase: UpdatePhase + let percentage: Double + let bytesDownloaded: Int64 + let totalBytes: Int64 + let estimatedTimeRemaining: TimeInterval? + let downloadSpeed: Double? + + enum UpdatePhase: String, Sendable { + case checking, downloading, extracting, verifying, installing, cleaning, completed, failed + } + + init( + phase: UpdatePhase, + percentage: Double, + bytesDownloaded: Int64 = 0, + totalBytes: Int64 = 0, + estimatedTimeRemaining: TimeInterval? = nil, + downloadSpeed: Double? = nil + ) { + self.phase = phase + self.percentage = percentage + self.bytesDownloaded = bytesDownloaded + self.totalBytes = totalBytes + self.estimatedTimeRemaining = estimatedTimeRemaining + self.downloadSpeed = downloadSpeed + } +} + +// MARK: - UpdateError + +enum UpdateError: LocalizedError, Sendable { + case network(Error) + case invalidManifest(String? = nil) + case checksumMismatch + case installationFailed(String) + case incompatibleSystem(String) + case security(String) + case timeout + case http(Int) + + var errorDescription: String? { + switch self { + case let .network(error): + "Network error: \(error.localizedDescription)" + case let .invalidManifest(details): + details.map { "Invalid update manifest: \($0)" } ?? "Invalid update manifest" + case .checksumMismatch: + "File integrity check failed" + case let .installationFailed(reason): + "Installation failed: \(reason)" + case let .incompatibleSystem(reason): + reason + case let .security(reason): + "Security error: \(reason)" + case .timeout: + "Request timed out" + case let .http(code): + "HTTP error (\(code))" + } + } + + var isRetryable: Bool { + switch self { + case let .network(error): + if let urlError = error as? URLError { + return [.timedOut, .cannotConnectToHost, .networkConnectionLost, .notConnectedToInternet, .dnsLookupFailed].contains(urlError.code) + } + return false + case .timeout: + return true + case let .http(code) where code >= 500: + return true + default: + return false + } + } + + // Convenience constructors + static func httpError(_ response: HTTPURLResponse) -> UpdateError { + .http(response.statusCode) + } +} + +// MARK: - GitHubRelease Model + +struct GitHubRelease: Codable { + var id: Int + var tagName: String + var name: String + var body: String + var assets: [Asset] + var prerelease: Bool + var createdAt: Date + var updatedAt: Date + var publishedAt: Date + + var buildNumber: Int? + + enum CodingKeys: String, CodingKey { + case id, tagName = "tag_name", name, body, assets, prerelease, createdAt = "created_at", updatedAt = "updated_at", publishedAt = "published_at" + } + + struct Asset: Codable { + var name: String + var browserDownloadURL: URL + var size: Int + var digest: String? + + enum CodingKeys: String, CodingKey { + case name + case browserDownloadURL = "browser_download_url" + case size + case digest + } + } +} + +// MARK: - InstallState + +enum InstallState: Equatable { + case ready + case installing + case readyToRestart + case failed(any Error) + + static func == (lhs: InstallState, rhs: InstallState) -> Bool { + switch (lhs, rhs) { + case (.ready, .ready), + (.installing, .installing), + (.readyToRestart, .readyToRestart): + true + case let (.failed(lhsErr), .failed(rhsErr)): + lhsErr.localizedDescription == rhsErr.localizedDescription + default: + false + } + } + + var label: String { + switch self { + case .ready: + String(localized: "Install") + case .installing: + " " // Helps with alignment for the animation once the update finishes + case .readyToRestart: + String(localized: "Relaunch to complete") + case .failed: + String(localized: "Install failed") + } + } + + var isUpdateButtonInteractive: Bool { + switch self { + case .ready, .readyToRestart: + true + case .installing, .failed: + false + } + } + + var isCancelButtonInteractive: Bool { + switch self { + case .ready, .failed: + true + case .readyToRestart, .installing: + false + } + } + + var errorDescription: String? { + if case let .failed(error) = self { + error.localizedDescription + } else { + nil + } + } + + var isFailure: Bool { + if case .failed = self { + true + } else { + false + } + } +} + +// MARK: - UpdateAvailability + +enum UpdateAvailability { + case available + case unavailable + case osNotSupported + + var text: String { + switch self { + case .unavailable: + String(localized: "Check for updates…") + case .available: + String(localized: "Update…") + case .osNotSupported: + String(localized: "This macOS version is no longer supported.") + } + } +} diff --git a/Loop/Updater/Utilities/BackupManager.swift b/Loop/Updater/Utilities/BackupManager.swift new file mode 100644 index 00000000..55d84d55 --- /dev/null +++ b/Loop/Updater/Utilities/BackupManager.swift @@ -0,0 +1,138 @@ +// +// BackupManager.swift +// Loop +// +// Created by Kai Azim on 2026-01-23. +// + +import Foundation +import Scribe + +@Loggable +actor BackupManager { + private let fileManager: FileManager + + private var backupDirectory: URL { SystemPaths.backupsDirectory } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return formatter + }() + + private static let maxBackupSize: Int64 = 104_857_600 // 100MB + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + // MARK: - Public Interface + + /// Ensures backup directory exists and cleans up old backups if size exceeds limit + func prepareForBackup() async throws { + try fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + + let backupSize = try calculateDirectorySize(backupDirectory) + + guard backupSize > Self.maxBackupSize else { return } + + log.info("Backup directory exceeds 100MB (\(backupSize.formattedBytes)), cleaning up old backups") + try await cleanupOldBackups(currentSize: backupSize, maxSize: Self.maxBackupSize) + } + + /// Creates a unique backup URL for the current app version + /// - Returns: URL where the backup should be stored + func createBackupURL() throws -> URL { + let baseTimestamp = Self.dateFormatter.string(from: Date()) + let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + + var backupName = "backup_\(currentVersion)_\(baseTimestamp)" + var backupURL = backupDirectory.appendingPathComponent(backupName) + + // If collision detected, add microseconds and retry up to 10 times + var attempt = 0 + while fileManager.fileExists(atPath: backupURL.path), attempt < 10 { + attempt += 1 + let microTimestamp = String(format: "%06d", Int(Date().timeIntervalSince1970 * 1_000_000) % 1_000_000) + backupName = "install_backup_\(currentVersion)_\(baseTimestamp)_\(microTimestamp)" + backupURL = backupDirectory.appendingPathComponent(backupName) + } + + // Final check for collision + guard !fileManager.fileExists(atPath: backupURL.path) else { + throw UpdateError.installationFailed("Could not generate unique install backup name after \(attempt) attempts") + } + + return backupURL + } + + /// Restores the application from a backup after a failed installation + /// - Parameters: + /// - currentURL: The current (possibly corrupted) app location + /// - backupURL: The backup location to restore from + func restoreFromBackup(currentURL: URL, backupURL: URL) throws { + guard fileManager.fileExists(atPath: backupURL.path) else { + log.warn("No backup found to restore from at: \(backupURL.path)") + return + } + + log.info("Attempting to restore from backup...") + try? fileManager.removeItem(at: currentURL) + try? fileManager.moveItem(at: backupURL, to: currentURL) + log.info("Restored from backup") + } + + // MARK: - Private Methods + + private func cleanupOldBackups(currentSize: Int64, maxSize: Int64) async throws { + let backups = try getBackupsSortedByDate() + var remainingSize = currentSize + + for (backupURL, _) in backups { + guard remainingSize > maxSize else { break } + + let backupItemSize = try calculateDirectorySize(backupURL) + try fileManager.removeItem(at: backupURL) + remainingSize -= backupItemSize + + log.info("Removed old backup: \(backupURL.lastPathComponent) (\(backupItemSize.formattedBytes))") + } + + log.info("Backup cleanup completed, new size: \(remainingSize.formattedBytes)") + } + + private func getBackupsSortedByDate() throws -> [(URL, Date)] { + try fileManager.contentsOfDirectory( + at: backupDirectory, + includingPropertiesForKeys: [.creationDateKey], + options: [.skipsHiddenFiles] + ) + .compactMap { url -> (URL, Date)? in + guard let date = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate else { return nil } + return (url, date) + } + .sorted { $0.1 < $1.1 } + } + + private func calculateDirectorySize(_ url: URL) throws -> Int64 { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return 0 } + + if !isDirectory.boolValue { + let attributes = try fileManager.attributesOfItem(atPath: url.path) + return (attributes[.size] as? Int64) ?? 0 + } + + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { return 0 } + + return Int64(enumerator + .compactMap { $0 as? URL } + .compactMap { try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize } + .reduce(0, +) + ) + } +} diff --git a/Loop/Updater/Utilities/BundleUtilities.swift b/Loop/Updater/Utilities/BundleUtilities.swift new file mode 100644 index 00000000..a6bd2478 --- /dev/null +++ b/Loop/Updater/Utilities/BundleUtilities.swift @@ -0,0 +1,145 @@ +// +// BundleUtilities.swift +// Loop +// +// Created by Kai Azim on 2026-01-23. +// + +import Foundation +import Scribe + +@Loggable(style: .static) +enum BundleUtilities { + /// Required paths that must exist in a valid app bundle + static let requiredBundlePaths = ["Contents/Info.plist", "Contents/MacOS"] + + /// Recursively searches for an app bundle (.app) within a directory + /// - Parameter directory: The directory to search in + /// - Returns: URL to the found app bundle + /// - Throws: `UpdateError.installationFailed` if no app bundle is found + static func findAppBundle(in directory: URL) throws -> URL { + let contents = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey] + ) + + for item in contents { + if item.pathExtension == "app" { + log.info("Found app bundle: \(item.lastPathComponent)") + return item + } + + let resourceValues = try item.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true, + let found = try? findAppBundle(in: item) { + return found + } + } + + let fileList = contents.map(\.lastPathComponent).joined(separator: ", ") + log.error("No .app bundle found in directory. Available files: \(fileList)") + + throw UpdateError.installationFailed("No .app bundle found in update package. Found files: \(fileList)") + } + + /// Verifies that a bundle has the required structure (Info.plist and MacOS directory) + /// - Parameter bundleURL: URL to the app bundle to verify + /// - Throws: `UpdateError.installationFailed` if required paths are missing + static func verifyBundleStructure(_ bundleURL: URL) throws { + log.debug("Verifying bundle structure for: \(bundleURL.path)") + + for path in requiredBundlePaths { + let fullPath = bundleURL.appendingPathComponent(path) + guard FileManager.default.fileExists(atPath: fullPath.path) else { + log.error("Missing required path: \(path)") + throw UpdateError.installationFailed("Invalid app bundle: missing \(path)") + } + } + + log.debug("Bundle structure verification passed") + } + + /// Returns the path to the executable for a given bundle + /// - Parameter bundleURL: URL to the app bundle + /// - Returns: URL to the executable + /// - Throws: `UpdateError.installationFailed` if executable name cannot be determined + static func executablePath(for bundleURL: URL) throws -> URL { + let infoPlistURL = bundleURL.appendingPathComponent("Contents/Info.plist") + + guard let plist = NSDictionary(contentsOf: infoPlistURL), + let executableName = plist["CFBundleExecutable"] as? String else { + throw UpdateError.installationFailed("Could not determine executable name from Info.plist") + } + + return bundleURL.appendingPathComponent("Contents/MacOS/\(executableName)") + } + + // MARK: Version Checking + + /// Version information extracted from a bundle's Info.plist + struct VersionInfo { + let version: String + let build: Int + + /// Returns the normalized version string (removes dev build emoji and trims whitespace) + var normalizedVersion: String { + version + .replacing(/🧪/, with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + /// Verifies that a bundle's version matches the expected manifest + /// - Parameters: + /// - bundleURL: URL to the app bundle to verify + /// - manifest: The update manifest to compare against + /// - Throws: `UpdateError.installationFailed` if versions don't match + static func verifyVersionMatches(bundleURL: URL, manifest: UpdateManifest) throws { + guard let versionInfo = readVersionInfo(from: bundleURL) else { + throw UpdateError.installationFailed("Could not read version info from bundle at: \(bundleURL.path)") + } + + try verifyVersionMatches(versionInfo: versionInfo, manifest: manifest) + } + + /// Reads version information from a bundle's Info.plist + /// - Parameter bundleURL: URL to the app bundle + /// - Returns: VersionInfo if successfully read, nil otherwise + private static func readVersionInfo(from bundleURL: URL) -> VersionInfo? { + let infoPlistURL = bundleURL.appendingPathComponent("Contents/Info.plist") + + guard let plist = NSDictionary(contentsOf: infoPlistURL), + let version = plist["CFBundleShortVersionString"] as? String, + let buildString = plist["CFBundleVersion"] as? String, + let build = Int(buildString) else { + log.error("Could not read version info from Info.plist at: \(infoPlistURL.path)") + return nil + } + + return VersionInfo(version: version, build: build) + } + + /// Verifies that version info matches the expected manifest + /// - Parameters: + /// - versionInfo: The version info to verify + /// - manifest: The update manifest to compare against + /// - Throws: `UpdateError.installationFailed` if versions don't match + private static func verifyVersionMatches(versionInfo: VersionInfo, manifest: UpdateManifest) throws { + log.info("Verifying version - Bundle: \(versionInfo.version) (\(versionInfo.build)), Expected: \(manifest.version) (\(manifest.buildNumber))") + + guard versionInfo.normalizedVersion == manifest.version else { + log.error("Version mismatch - Expected: \(manifest.version), Got: \(versionInfo.normalizedVersion)") + throw UpdateError.installationFailed("Version mismatch: expected \(manifest.version), got \(versionInfo.normalizedVersion)") + } + + // For non-stable channels, also verify build number + if manifest.channel != .stable { + guard versionInfo.build == manifest.buildNumber else { + log.error("Build number mismatch - Expected: \(manifest.buildNumber), Got: \(versionInfo.build)") + throw UpdateError.installationFailed("Build number mismatch: expected \(manifest.buildNumber), got \(versionInfo.build)") + } + } + + log.success("Version verification passed") + } +} diff --git a/Loop/Updater/Utilities/ChangelogParser.swift b/Loop/Updater/Utilities/ChangelogParser.swift new file mode 100644 index 00000000..09503121 --- /dev/null +++ b/Loop/Updater/Utilities/ChangelogParser.swift @@ -0,0 +1,191 @@ +// +// ChangelogParser.swift +// Loop +// +// Created by Kai Azim on 2026-01-23. +// + +import Foundation +import Scribe + +@Loggable(style: .static) +enum ChangelogParser { + static func parse(_ body: String) -> [ChangelogSection] { + var result: [ChangelogSection] = [] + + let lines = body.split(whereSeparator: \.isNewline) + var currentSectionID: String? + var totalNotesCount = 0 + + for line in lines where !line.isEmpty { + if let header = parseHeader(from: line) { + currentSectionID = header + + if result.first(where: { $0.id == header }) == nil { + let emoji = detectEmoji(line: header) + + if emoji == nil { + log.warn("Failed to parse emoji in: \(header)") + } + + let title = header + .drop { $0.hasEmojiPresentationAsDefault } + .trimmingCharacters(in: .whitespacesAndNewlines) + + let newSection = ChangelogSection( + id: header, // uses full header name for id + emoji: emoji ?? "🔄", + title: title, + notes: [] + ) + + log.debug("Parsed new section: '\(newSection.title)'") + + result.append(newSection) + } + + continue + } + + if let note = parseNote(from: line) { + guard let index = result.firstIndex(where: { $0.id == currentSectionID }) else { + // If the section doesn't exist (e.g. malformed changelog ordering), skip safely. + log.warn("Failed to find section for '\(currentSectionID ?? "")'") + continue + } + + result[index].notes.append(note) + totalNotesCount += 1 + + continue + } + + log.debug("Skipping line: '\(line)'") + } + + let sectionsToRemove = result.filter(\.notes.isEmpty) + if !sectionsToRemove.isEmpty { + result.removeAll { sectionsToRemove.contains($0) } + log.debug("Removed empty changelog sections: \(sectionsToRemove.map(\.id))") + } + + log.success("Finished parsing changelog with a total of \(totalNotesCount) notes") + + return result + } + + private static func parseHeader(from line: Substring) -> String? { + guard line.starts(with: "#") else { return nil } + + let header = line + .replacing(/#/, with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + return header.isEmpty ? nil : header + } + + private static func parseNote(from line: Substring) -> ChangelogNote? { + guard line.hasPrefix("- ") else { + return nil + } + + let cleanedLine = line + .replacing(#/- /#, with: "") + .trimmingCharacters(in: .whitespaces) + + let user: String? + let reference: Int? + + if let match = cleanedLine.firstMatch(of: /(@(?\w+))/) { + user = String(match.user) + } else { + user = nil + } + + if let match = cleanedLine.firstMatch(of: /#(?\d+)/) { + reference = Int(String(match.reference)) + } else { + reference = nil + } + + let noteText = cleanedLine + .replacing(#/#\d+/#, with: "") // Issue # + .replacing(#/(@.*?)/#, with: "") // Mentions + .trimmingCharacters(in: .whitespacesAndNewlines) + + let emoji = detectEmoji(line: noteText) + let text = noteText.drop { $0.hasEmojiPresentationAsDefault } + + return ChangelogNote( + emoji: emoji, + text: String(text), + user: user, + reference: reference + ) + } + + private static func detectEmoji(line: String) -> Character? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + log.warn("Failed to detect emoji inside '\(line)'; empty string") + return nil + } + + for char in trimmed { + let hasEmoji = char.unicodeScalars.contains { scalar in + scalar.properties.isEmoji || scalar.properties.isEmojiPresentation + } + + if hasEmoji { + return char + } + } + + return nil + } +} + +// MARK: - ChangelogSection + +struct ChangelogSection: Identifiable, Equatable { + let id: String + let emoji: Character + let title: String + var notes: [ChangelogNote] +} + +// MARK: - ChangelogNote + +struct ChangelogNote: Identifiable, Equatable { + let id: UUID = .init() + let emoji: Character? + let text: String + let user: String? + let reference: Int? +} + +// MARK: - Character emoji detection + +private extension Character { + var hasEmojiPresentationAsDefault: Bool { + let scalars = unicodeScalars + + /// Must contain at least one emoji-capable scalar + guard scalars.contains(where: \.properties.isEmoji) else { + return false + } + + /// If any scalar defaults to emoji, it's an emoji + if scalars.contains(where: \.properties.isEmojiPresentation) { + return true + } + + /// If it contains the emojification codepoint (U+FE0F, Variation Selector-16) + if scalars.contains(where: { $0.value == 0xFE0F }) { + return true + } + + return false + } +} diff --git a/Loop/Updater/Utilities/ChecksumVerifier.swift b/Loop/Updater/Utilities/ChecksumVerifier.swift new file mode 100644 index 00000000..af3b0d37 --- /dev/null +++ b/Loop/Updater/Utilities/ChecksumVerifier.swift @@ -0,0 +1,40 @@ +// +// ChecksumVerifier.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import CryptoKit +import Foundation +import Scribe + +@Loggable(style: .static) +enum ChecksumVerifier { + static func verifyFile(_ fileURL: URL, expectedChecksum: String) async throws { + Log.debug("Starting checksum calculation for file: \(fileURL.path)") + let actualChecksum = try await calculateSHA256(fileURL) + let isMatch = actualChecksum == expectedChecksum + + guard isMatch else { + Log.error("Checksum mismatch - File: \(fileURL.path)") + throw UpdateError.checksumMismatch + } + + Log.debug("Checksum verification completed successfully") + } + + @concurrent + private static func calculateSHA256(_ fileURL: URL) async throws -> String { + Log.debug("Calculating SHA256 for file - File: \(fileURL.path), Exists: \(FileManager.default.fileExists(atPath: fileURL.path))") + + let data = try Data(contentsOf: fileURL) + Log.debug("File data loaded - Size: \(data.count) bytes, File: \(fileURL.lastPathComponent)") + + let digest = SHA256.hash(data: data) + let checksum = digest.compactMap { String(format: "%02x", $0) }.joined() + + Log.debug("SHA256 calculation complete - Checksum: \(checksum), File: \(fileURL.lastPathComponent)") + return checksum + } +} diff --git a/Loop/Updater/Utilities/HTTPClient.swift b/Loop/Updater/Utilities/HTTPClient.swift new file mode 100644 index 00000000..b825b347 --- /dev/null +++ b/Loop/Updater/Utilities/HTTPClient.swift @@ -0,0 +1,50 @@ +// +// HTTPClient.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import Foundation +import Scribe + +@Loggable(style: .static) +final class HTTPClient: Sendable { + private let session: URLSession + private let jsonDecoder: JSONDecoder + + init() { + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = 30.0 + sessionConfig.httpAdditionalHeaders = [ + "User-Agent": "Loop/\(Bundle.main.appVersion ?? "1.0.0") (\(SystemInfo.deviceModel); \(ProcessInfo.processInfo.operatingSystemVersion))", + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate" + ] + + self.session = URLSession(configuration: sessionConfig) + self.jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .iso8601 + } + + deinit { + session.invalidateAndCancel() + } + + func fetchData(from url: URL) async throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = "GET" + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw UpdateError.network(URLError(.badServerResponse)) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw UpdateError.httpError(httpResponse) + } + + return data + } +} diff --git a/Loop/Updater/Utilities/SystemInfo.swift b/Loop/Updater/Utilities/SystemInfo.swift new file mode 100644 index 00000000..0432dc57 --- /dev/null +++ b/Loop/Updater/Utilities/SystemInfo.swift @@ -0,0 +1,34 @@ +// +// SystemInfo.swift +// Loop +// +// Created by Kami on 2026-01-22. +// + +import Foundation + +enum SystemInfo { + static var deviceModel: String { + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var model = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &model, &size, nil, 0) + return String(cString: model) + } + + enum Architecture: String, CaseIterable { + case x86_64 + case arm64 + case other + } + + static var architecture: Architecture { + #if arch(x86_64) + return .x86_64 + #elseif arch(arm64) + return .arm64 + #else + return .other + #endif + } +} diff --git a/Loop/Updater/Utilities/SystemPaths.swift b/Loop/Updater/Utilities/SystemPaths.swift new file mode 100644 index 00000000..d1c06b82 --- /dev/null +++ b/Loop/Updater/Utilities/SystemPaths.swift @@ -0,0 +1,18 @@ +// +// SystemPaths.swift +// Loop +// +// Created by Kai Azim on 2026-01-23. +// + +import Foundation + +enum SystemPaths { + private static let appSupportDirectory: URL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + + static let loopDirectory: URL = appSupportDirectory.appendingPathComponent("Loop", isDirectory: true) + static let backupsDirectory: URL = loopDirectory.appendingPathComponent("Backups", isDirectory: true) +} diff --git a/Loop/Updater/Utilities/ZipExtractor.swift b/Loop/Updater/Utilities/ZipExtractor.swift new file mode 100644 index 00000000..bd64e28c --- /dev/null +++ b/Loop/Updater/Utilities/ZipExtractor.swift @@ -0,0 +1,118 @@ +// +// ZipExtractor.swift +// Loop +// +// Created by Kai Azim on 2026-01-23. +// + +import Foundation +import Scribe +import ZIPFoundation + +@Loggable(style: .static) +enum ZipExtractor { + /// Extracts a ZIP file to a temporary directory + /// - Parameters: + /// - zipURL: URL of the ZIP file to extract + /// - cancellationCheck: Optional closure to check if operation should be cancelled + /// - Returns: URL of the temporary directory containing extracted contents + /// - Throws: `UpdateError` if extraction fails + static func extract( + from zipURL: URL, + cancellationCheck: (() throws -> ())? = nil + ) throws -> URL { + log.info("Extracting update from: \(zipURL.path)") + + try validateZipFile(zipURL) + + let tempDir = createTemporaryDirectory() + + do { + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + try performExtraction(from: zipURL, to: tempDir, cancellationCheck: cancellationCheck) + try verifyExtractionContainsAppBundle(tempDir) + return tempDir + } catch { + // Clean up on failure + try? FileManager.default.removeItem(at: tempDir) + throw error + } + } + + // MARK: - Validation + + private static func validateZipFile(_ zipURL: URL) throws { + log.debug("Validating ZIP file: \(zipURL.path)") + + guard FileManager.default.fileExists(atPath: zipURL.path) else { + throw createError("ZIP file not found", zipURL: zipURL) + } + + guard FileManager.default.isReadableFile(atPath: zipURL.path) else { + throw createError("ZIP file is not readable", zipURL: zipURL) + } + + // Verify ZIP magic bytes (PK signature) + let fileHandle = try FileHandle(forReadingFrom: zipURL) + defer { fileHandle.closeFile() } + + let headerData = fileHandle.readData(ofLength: 4) + guard headerData.count >= 2 else { + throw createError("File is too small to be a valid ZIP archive", zipURL: zipURL) + } + + let (pk1, pk2) = (headerData[0], headerData[1]) + guard pk1 == 0x50, pk2 == 0x4B else { + throw createError("File is not a valid ZIP archive (invalid signature)", zipURL: zipURL) + } + + log.debug("ZIP file validation passed") + } + + // MARK: - Extraction + + private static func performExtraction( + from zipURL: URL, + to destinationURL: URL, + cancellationCheck: (() throws -> ())? + ) throws { + log.info("Extracting ZIP archive: \(zipURL.lastPathComponent)") + + guard let archive = try Archive(url: zipURL, accessMode: .read) else { + throw createError("Could not open ZIP archive", zipURL: zipURL) + } + + for entry in archive where !entry.path.contains(/__MACOSX/) { + try cancellationCheck?() + _ = try archive.extract(entry, to: destinationURL.appendingPathComponent(entry.path)) + } + + log.success("Successfully extracted ZIP archive") + } + + private static func verifyExtractionContainsAppBundle(_ extractedURL: URL) throws { + log.debug("Verifying extraction contains app bundle") + + // This will throw if no app bundle is found + let appBundle = try BundleUtilities.findAppBundle(in: extractedURL) + try BundleUtilities.verifyBundleStructure(appBundle) + + log.debug("Extraction verification passed") + } + + // MARK: - Utilities + + private static func createTemporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("LoopExtraction_\(UUID().uuidString)") + } + + private static func createError(_ message: String, zipURL: URL? = nil) -> UpdateError { + var fullMessage = message + if let zipURL { + let fileSize = try? FileManager.default.attributesOfItem(atPath: zipURL.path)[.size] as? Int64 + let fileSizeString = fileSize?.formattedBytes ?? "unknown size" + fullMessage = "\(message) at \(zipURL.path) (Size: \(fileSizeString))" + } + return .installationFailed(fullMessage) + } +} diff --git a/Loop/Updater/Views/ChangelogSectionView.swift b/Loop/Updater/Views/ChangelogSectionView.swift new file mode 100644 index 00000000..ea1be047 --- /dev/null +++ b/Loop/Updater/Views/ChangelogSectionView.swift @@ -0,0 +1,113 @@ +// +// ChangelogSectionView.swift +// Loop +// +// Created by Kai Azim on 2026-01-23. +// + +import Luminare +import SwiftUI + +struct ChangelogSectionView: View { + @Environment(\.luminareAnimation) var luminareAnimation + + let section: ChangelogSection + let isExpanded: Bool + let onToggle: () -> () + + var body: some View { + LuminareSection { + ChangelogSectionHeader( + section: section, + isExpanded: isExpanded, + onToggle: onToggle + ) + + if isExpanded { + ForEach(section.notes, id: \.id) { note in + ChangelogItemView( + note: note, + sectionEmoji: section.emoji + ) + } + } + } + } +} + +private struct ChangelogSectionHeader: View { + let section: ChangelogSection + let isExpanded: Bool + let onToggle: () -> () + + var body: some View { + Button(action: onToggle) { + HStack(alignment: .top) { + Image(systemName: "chevron.forward") + .rotationEffect(isExpanded ? .degrees(90) : .zero) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Text(String(section.emoji)) + Text(LocalizedStringKey(section.title)) + .lineSpacing(1.1) + } + + Spacer() + } + .padding(8) + .frame(height: 34) + .contentShape(.rect) + .fontWeight(.medium) + } + .buttonStyle(.plain) + } +} + +private struct ChangelogItemView: View { + let note: ChangelogNote + let sectionEmoji: Character + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Text(String(note.emoji ?? sectionEmoji)) + .foregroundStyle(.secondary) + + Text(LocalizedStringKey(note.text)) + .lineSpacing(1.1) + + Spacer(minLength: 0) + + ChangelogMetadataView(note: note) + } + .padding(8) + .frame(minHeight: 34) + } +} + +private struct ChangelogMetadataView: View { + let note: ChangelogNote + + var body: some View { + HStack(spacing: 0) { + if let user = note.user { + Link(String("@\(user)"), destination: URL(string: "https://github.com/\(user)")!) + .frame(width: 105, alignment: .trailing) + } + + if note.user != nil, note.reference != nil { + Text(verbatim: "•") + .padding(.horizontal, 4) + } + + if let reference = note.reference { + Link(String("#\(reference)"), destination: URL(string: "https://github.com/MrKai77/Loop/issues/\(reference)")!) + .monospaced() + .fixedSize() + } + } + .foregroundStyle(.secondary) + .buttonStyle(.plain) + .fixedSize() + } +} diff --git a/Loop/Updater/TheLoopTimes.swift b/Loop/Updater/Views/TheLoopTimes.swift similarity index 100% rename from Loop/Updater/TheLoopTimes.swift rename to Loop/Updater/Views/TheLoopTimes.swift diff --git a/Loop/Updater/UpdateView.swift b/Loop/Updater/Views/UpdateView.swift similarity index 51% rename from Loop/Updater/UpdateView.swift rename to Loop/Updater/Views/UpdateView.swift index ace77e27..5b5201cf 100644 --- a/Loop/Updater/UpdateView.swift +++ b/Loop/Updater/Views/UpdateView.swift @@ -18,8 +18,6 @@ struct UpdateView: View { @Default(.currentIcon) private var currentIcon @State private var isShowingTheLoopTimes: Bool = false - @State private var isInstalling: Bool = false - @State private var readyToRestart: Bool = false var body: some View { VStack(spacing: 0) { @@ -115,7 +113,7 @@ struct UpdateView: View { private func updateDateView() -> some View { ZStack { - if let updateDate = updater.targetRelease?.updateDate { + if let updateDate = updater.updateManifest?.publishedAt { Text(updateDate.formatted(date: .complete, time: .shortened)) .fontDesign(.serif) .foregroundStyle(.tertiary) @@ -134,34 +132,20 @@ struct UpdateView: View { } } + @ViewBuilder private func versionChangeText() -> some View { HStack { - if let targetRelease = updater.targetRelease { - let devBuildEmoji = "🧪" - let currentIsDevBuild: Bool = Bundle.main.appVersion?.contains(devBuildEmoji) ?? false - let targetIsDevBuild = targetRelease.prerelease - - let currentVersionBase = Bundle.main.appVersion?.replacing(devBuildEmoji, with: "").trimmingCharacters(in: .whitespaces) - let targetVersionBase = targetRelease.tagName.replacing(devBuildEmoji, with: "").trimmingCharacters(in: .whitespaces) + let currentVersion = VersionDisplay.current - let currentVersionBuild = currentIsDevBuild ? " (\(Bundle.main.appBuild ?? 0))" : "" - let targetVersionBuild = targetIsDevBuild ? " (\(targetRelease.buildNumber ?? 0))" : "" - - let currentVersion = "\(currentIsDevBuild ? devBuildEmoji : "")\(currentVersionBase ?? "Unknown")\(currentVersionBuild)" - Text(currentVersion) + if let targetRelease = updater.updateManifest { + let targetVersion = targetRelease.versionDisplay() + Text(currentVersion.shortDisplay) Image(systemName: "arrow.right") - - let targetVersion = "\(targetIsDevBuild ? devBuildEmoji : "")\(targetVersionBase)\(targetVersionBuild)" - Text(targetVersion) + Text(targetVersion.shortDisplay) } else { - let currentVersion = Bundle.main.appVersion ?? "Unknown" - Text(currentVersion) - - Image(systemName: "arrow.right") - - let targetVersion = "Unknown" - Text(targetVersion) + Text("Update from: \(Text(currentVersion.shortDisplay))") + .fontWeight(.semibold) } } } @@ -169,24 +153,22 @@ struct UpdateView: View { private func changelogView() -> some View { ScrollView(showsIndicators: false) { VStack { // Using LazyVStack seems to cause visual glitches - ForEach(updater.changelog, id: \.title) { item in - if !item.body.isEmpty { - ChangelogSectionView( - isExpanded: Binding( - get: { - updater.expandedChangelogSections.contains(item.title) - }, - set: { newValue in - if newValue { - updater.expandedChangelogSections.insert(item.title) - } else { - updater.expandedChangelogSections.remove(item.title) - } + ForEach(updater.changelog) { section in + let isExpanded = updater.expandedChangelogSections.contains(section.id) + + ChangelogSectionView( + section: section, + isExpanded: isExpanded, + onToggle: { + withAnimation(.smooth(duration: 0.25)) { + if isExpanded { + updater.expandedChangelogSections.remove(section.id) + } else { + updater.expandedChangelogSections.insert(section.id) } - ), - item: item - ) - } + } + } + ) } } .padding(.top, 10) @@ -196,34 +178,40 @@ struct UpdateView: View { private func footerView() -> some View { HStack { - Button("Remind me later") { + Button { Updater.shared.dismissWindow() + } label: { + Text(updater.installState.isFailure ? "Try again later" : "Remind me later") + .contentTransition(.numericText()) + .padding(.trailing, 4) + .luminarePopover(attachedTo: .topTrailing, hidden: updater.installState.errorDescription == nil) { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.secondary) + .padding(4) + + Text(updater.installState.errorDescription ?? "") + .multilineTextAlignment(.leading) + .frame(maxWidth: 300, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + } } - .disabled(isInstalling || readyToRestart) - - Button { - if readyToRestart { - AppDelegate.relaunch() - } + .disabled(updater.installState == .installing || updater.installState == .readyToRestart) - withAnimation(luminareAnimation) { - isInstalling = true - } + Button(role: updater.installState.isFailure ? .destructive : nil) { Task { - await Updater.shared.installUpdate() - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - withAnimation(luminareAnimation) { - isInstalling = false - } - withAnimation(luminareAnimation) { - readyToRestart = true - } + if updater.installState == .readyToRestart { + await Updater.shared.relaunchAfterUpdate() + return } + + try await Updater.shared.downloadAndInstallUpdate() } } label: { ZStack { - if isInstalling { + if updater.installState == .installing { Capsule() .frame(maxWidth: .infinity) .frame(height: 5) @@ -242,16 +230,17 @@ struct UpdateView: View { .padding(.horizontal, 12) } - let tenSpaces = " " // This helps with alignment for the animation once the update finishes - Text(isInstalling ? tenSpaces : readyToRestart ? NSLocalizedString("Relaunch to complete", comment: "") : NSLocalizedString("Install", comment: "")) + Text(updater.installState.label) .contentTransition(.numericText()) - .opacity(isInstalling ? 0 : 1) + .opacity(updater.installState == .installing ? 0 : 1) + .opacity(updater.installState.isFailure ? 0.5 : 1.0) } } - .allowsHitTesting(!isInstalling) + .allowsHitTesting(updater.installState.isUpdateButtonInteractive) } .luminareCornerRadius(8) .padding(12) + .animation(luminareAnimation, value: updater.installState) .overlay { VStack { Divider() @@ -261,76 +250,3 @@ struct UpdateView: View { .fixedSize(horizontal: false, vertical: true) } } - -struct ChangelogSectionView: View { - @Environment(\.luminareAnimation) var luminareAnimation - @Environment(\.luminareCornerRadii) var luminareCornerRadii - - @Binding var isExpanded: Bool - let item: (title: String, body: [Updater.ChangelogNote]) - - var body: some View { - LuminareSection { - Button { - withAnimation(luminareAnimation) { - isExpanded.toggle() - } - } label: { - HStack { - Image(systemName: "chevron.forward") - .bold() - .rotationEffect(isExpanded ? .degrees(90) : .zero) - - Text(LocalizedStringKey(item.title)) - .font(.headline) - .lineLimit(1) - - Spacer() - } - .padding(.horizontal, 8) - .frame(height: 34) - .contentShape(.rect) - } - .buttonStyle(.plain) - - if isExpanded { - ForEach(item.body, id: \.id) { note in - HStack(spacing: 8) { - Text(note.emoji) - Text(LocalizedStringKey(note.text)) - .lineSpacing(1.1) - - Spacer(minLength: 0) - - HStack(spacing: 0) { - if let user = note.user { - let text = "@\(user)" - Link(text, destination: URL(string: "https://github.com/\(user)")!) - .frame(width: 105, alignment: .trailing) - } - - if note.user != nil, note.user != nil { - let text = "•" // Prevents unnecessary localization entries - Text(text) - .padding(.horizontal, 4) - } - - if let reference = note.reference { - let text = "#\(reference)" - Link(text, destination: URL(string: "https://github.com/MrKai77/Loop/issues/\(reference)")!) - .frame(width: 35, alignment: .leading) - .monospaced() - } - } - .foregroundStyle(.secondary) - .buttonStyle(.plain) - .fixedSize() - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .frame(minHeight: 34) - } - } - } - } -} diff --git a/Loop/Updater/Views/VersionDisplay.swift b/Loop/Updater/Views/VersionDisplay.swift new file mode 100644 index 00000000..c64e3d79 --- /dev/null +++ b/Loop/Updater/Views/VersionDisplay.swift @@ -0,0 +1,73 @@ +// +// VersionDisplay.swift +// Loop +// +// Created by Kai Azim on 2026-01-22. +// + +import SwiftUI + +struct VersionDisplay { + let shortDisplay: String + let fullDisplay: String + let isPrerelease: Bool + + static let unknown: VersionDisplay = .init(shortDisplay: "Unknown", fullDisplay: "Unknown", isPrerelease: false) + + static let current: VersionDisplay = { + guard let version = Bundle.main.appVersion, + let build = Bundle.main.appBuild + else { + return .unknown + } + + #if !RELEASE + return .format(version: version, build: build, isPrerelease: true) + #else + return .format(version: version, build: build, isPrerelease: false) + #endif + }() + + static func format(version: String?, build: Int?, isPrerelease: Bool) -> VersionDisplay { + guard let version else { + return .unknown + } + + let devBuildEmoji = "🧪" + let shouldTreatAsPrerelease = isPrerelease || version.contains(devBuildEmoji) + + let buildString = if let build { "(\(build))" } else { "" } + + let baseVersion = version + .replacing(devBuildEmoji, with: "") + .trimmingCharacters(in: .whitespaces) + + let shortDisplay: String = if shouldTreatAsPrerelease { + "🧪 \(baseVersion) \(buildString)" + } else { + baseVersion + } + + let fullDisplay = if shouldTreatAsPrerelease { + "🧪 \(baseVersion) \(buildString)" + } else { + "\(baseVersion) \(buildString)" // Always show build number + } + + return VersionDisplay( + shortDisplay: shortDisplay, + fullDisplay: fullDisplay, + isPrerelease: shouldTreatAsPrerelease + ) + } +} + +extension UpdateManifest { + func versionDisplay() -> VersionDisplay { + VersionDisplay.format( + version: version, + build: buildNumber, + isPrerelease: channel != .stable + ) + } +} diff --git a/Loop/Utilities/AccessibilityManager.swift b/Loop/Utilities/AccessibilityManager.swift index 423e4801..195d27ac 100644 --- a/Loop/Utilities/AccessibilityManager.swift +++ b/Loop/Utilities/AccessibilityManager.swift @@ -136,13 +136,29 @@ final class AccessibilityManager { /// Executes `/usr/bin/tccutil reset Accessibility `. /// This fully removes any accessibility permissions the user may have previously granted to anything with Loop's bundle ID. private static func resetAccessibility() { - _ = try? Process.run(URL(filePath: "/usr/bin/tccutil"), arguments: ["reset", "Accessibility", Bundle.main.bundleID]) + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/tccutil") + process.arguments = ["reset", "Accessibility", Bundle.main.bundleID] + + // Redirect output and errors to /dev/null + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + try? process.run() } /// Executes `/usr/bin/tccutil reset ListenEvent `. /// This fully removes any input monitoring permissions the user may have previously granted to anything with Loop's bundle ID. private static func resetInputMonitoring() { - _ = try? Process.run(URL(filePath: "/usr/bin/tccutil"), arguments: ["reset", "ListenEvent", Bundle.main.bundleID]) + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/tccutil") + process.arguments = ["reset", "ListenEvent", Bundle.main.bundleID] + + // Redirect output and errors to /dev/null + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + try? process.run() } }