From d57eede41845c7f0fc080ad9b3d65802d75ced9e Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Fri, 3 Apr 2026 09:18:15 +1000 Subject: [PATCH 1/4] Always prevent display sleep and remove dock icon - Change all PowerAssertionType defaults from preventUserIdleSystemSleep to preventUserIdleDisplaySleep so screens stay on when caffeinated - Remove the "Prevent display sleep" settings toggle (always on now) - Remove the "Show dock icon" settings toggle (always hidden now) - Remove --display CLI flag from caffeinate and for commands - Delete unused DockIconController - Update CLAUDE.md window focus pattern docs Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: GitButler --- CLAUDE.md | 8 +- Sources/Insomnia/AppDelegate.swift | 26 +------ Sources/Insomnia/DockIconController.swift | 78 ------------------- .../ViewModels/SettingsViewModel.swift | 35 ++------- Sources/Insomnia/Views/AboutView.swift | 9 +-- Sources/Insomnia/Views/MenuBarView.swift | 7 +- Sources/Insomnia/Views/SettingsView.swift | 36 ++------- .../Commands/CaffeinateCommand.swift | 24 ++---- .../Commands/CaffeinateForCommand.swift | 13 +--- .../Models/InsomniaConfiguration.swift | 20 +---- .../Power/PowerAssertionManager.swift | 10 +-- .../Scheduling/CaffeinationScheduler.swift | 12 +-- .../Scheduling/ScheduleRule.swift | 4 +- .../PowerAssertionManagerTests.swift | 11 +-- .../PowerAssertionIntegrationTests.swift | 2 +- 15 files changed, 50 insertions(+), 245 deletions(-) delete mode 100644 Sources/Insomnia/DockIconController.swift diff --git a/CLAUDE.md b/CLAUDE.md index 120a7e7..3283c81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,8 +79,6 @@ This lets dev and prod run side-by-side. ## Window Focus Pattern LSUIElement apps need special handling to show windows: -1. `NSApp.setActivationPolicy(.regular)` before opening -2. `openWindow(id:)` to open -3. Reapply app icon via `AppDelegate.reapplyAppIcon()` -4. `NSApp.activate(ignoringOtherApps: true)` after short delay -5. Return to `.accessory` in `onDisappear` (unless dock icon enabled) +1. `openWindow(id:)` to open +2. `NSApp.activate(ignoringOtherApps: true)` after short delay +The app always stays in `.accessory` mode (no dock icon). diff --git a/Sources/Insomnia/AppDelegate.swift b/Sources/Insomnia/AppDelegate.swift index 497bb18..452d09e 100644 --- a/Sources/Insomnia/AppDelegate.swift +++ b/Sources/Insomnia/AppDelegate.swift @@ -1,8 +1,8 @@ // AppDelegate.swift — Insomnia GUI // // NSApplicationDelegate managing the application lifecycle. Owns the shared -// CaffeinationScheduler, IPCServer, and DockIconController. Starts the IPC -// server on launch and cleans up all resources on termination. +// CaffeinationScheduler and IPCServer. Starts the IPC server on launch +// and cleans up all resources on termination. import AppKit import InsomniaCore @@ -13,7 +13,6 @@ import InsomniaCore /// - Creates and owns the shared ``CaffeinationScheduler`` used by both /// the GUI and the IPC server /// - Starts the ``IPCServer`` on launch so the CLI can communicate -/// - Owns the ``DockIconController`` for dock tile updates /// - Releases all power assertions and stops the IPC server on termination final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Shared State @@ -31,12 +30,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Initialized lazily when the app finishes launching. private var ipcServer: IPCServer? - /// The dock icon controller for updating the dock tile image. - let dockIconController = DockIconController() - - /// The loaded app icon, cached so it can be reapplied when switching activation policies. - var cachedAppIcon: NSImage? - // MARK: - NSApplicationDelegate /// Called when the application finishes launching. @@ -58,7 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // This replaces the generic "exec" terminal icon when running via `swift run` loadAppIcon() - // Set the app's menu bar to accessory mode (no dock icon by default) + // Ensure the app runs as a menu bar-only app (no dock icon) NSApp.setActivationPolicy(.accessory) } @@ -81,18 +74,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Private Helpers - /// Loads AppIcon.icns and caches it for reuse when switching activation policies. + /// Loads AppIcon.icns and sets it as the application icon. private func loadAppIcon() { // Try loading from the app bundle first (release builds) if let bundleIcon = Bundle.main.image(forResource: "AppIcon") { - cachedAppIcon = bundleIcon NSApp.applicationIconImage = bundleIcon return } // Try from current working directory (most reliable for `swift run`) let cwdPath = FileManager.default.currentDirectoryPath + "/Resources/AppIcon.icns" if let icon = NSImage(contentsOfFile: cwdPath) { - cachedAppIcon = icon NSApp.applicationIconImage = icon return } @@ -102,19 +93,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { for _ in 0..<6 { let iconPath = searchDir.appendingPathComponent("Resources/AppIcon.icns").path if let icon = NSImage(contentsOfFile: iconPath) { - cachedAppIcon = icon NSApp.applicationIconImage = icon return } searchDir = searchDir.deletingLastPathComponent() } } - - /// Reapplies the cached app icon. Call after switching activation policy - /// since macOS resets the icon when toggling between .regular and .accessory. - func reapplyAppIcon() { - if let icon = cachedAppIcon { - NSApp.applicationIconImage = icon - } - } } diff --git a/Sources/Insomnia/DockIconController.swift b/Sources/Insomnia/DockIconController.swift deleted file mode 100644 index 7b43814..0000000 --- a/Sources/Insomnia/DockIconController.swift +++ /dev/null @@ -1,78 +0,0 @@ -// DockIconController.swift — Insomnia GUI -// -// Manages the dock tile icon by generating simple programmatic placeholder -// images based on the current PowerState. Real artwork will replace these -// placeholders in a future release — for now, colored circles provide -// clear visual feedback (green = awake, gray = sleep). - -import AppKit -import InsomniaCore - -/// Controls the application's dock tile icon based on caffeination state. -/// -/// Generates simple colored circle icons programmatically and applies them -/// to the dock tile via `NSApp.applicationIconImage`. Call ``update(for:)`` -/// whenever the power state changes. -final class DockIconController { - // MARK: - Constants - - /// The size of the generated dock icon images in points. - private static let iconSize: CGFloat = 128 - - // MARK: - Public API - - /// Updates the dock tile icon to reflect the given power state. - /// - /// Generates a colored circle image: - /// - Green for any active caffeination state - /// - Gray for the decaffeinated (idle) state - /// - /// - Parameter state: The current power state to represent visually. - func update(for state: PowerState) { - // Generate the appropriate icon based on activity state - let icon = state.isActive ? makeAwakeIcon() : makeSleepIcon() - // Apply the icon to the running application - NSApp.applicationIconImage = icon - // Force the dock tile to redraw immediately - NSApp.dockTile.display() - } - - // MARK: - Private Icon Generators - - /// Creates a green circle icon representing the awake/caffeinated state. - /// - /// - Returns: An `NSImage` with a green filled circle. - private func makeAwakeIcon() -> NSImage { - // Green circle signals active caffeination - return makeCircleIcon(color: .systemGreen) - } - - /// Creates a gray circle icon representing the sleep/decaffeinated state. - /// - /// - Returns: An `NSImage` with a gray filled circle. - private func makeSleepIcon() -> NSImage { - // Gray circle signals inactive/idle state - return makeCircleIcon(color: .systemGray) - } - - /// Generates a simple filled circle icon of the given color. - /// - /// The circle is drawn centered within a square canvas of - /// ``iconSize`` x ``iconSize`` points. - /// - /// - Parameter color: The fill color for the circle. - /// - Returns: An `NSImage` containing the colored circle. - private func makeCircleIcon(color: NSColor) -> NSImage { - let size = NSSize(width: Self.iconSize, height: Self.iconSize) - // Create the image and draw into its graphics context - let image = NSImage(size: size) - image.lockFocus() - // Fill the circle within the full bounds - let rect = NSRect(origin: .zero, size: size) - let path = NSBezierPath(ovalIn: rect.insetBy(dx: 8, dy: 8)) - color.setFill() - path.fill() - image.unlockFocus() - return image - } -} diff --git a/Sources/Insomnia/ViewModels/SettingsViewModel.swift b/Sources/Insomnia/ViewModels/SettingsViewModel.swift index 9026302..4a9798b 100644 --- a/Sources/Insomnia/ViewModels/SettingsViewModel.swift +++ b/Sources/Insomnia/ViewModels/SettingsViewModel.swift @@ -1,8 +1,8 @@ // SettingsViewModel.swift — Insomnia GUI // // Bridges the InsomniaConfiguration to SwiftUI settings views by exposing -// bindable properties and handling side effects like dock icon visibility -// and launch-at-login registration via SMAppService. +// bindable properties and handling side effects like launch-at-login +// registration via SMAppService. import Foundation import SwiftUI @@ -14,8 +14,8 @@ import ServiceManagement /// View model for the Settings window, binding user preferences to the UI. /// /// Wraps ``InsomniaConfiguration`` and adds side-effect handling for settings -/// that require AppKit or ServiceManagement calls (dock icon visibility, -/// launch at login). Uses `@Observable` for SwiftUI integration. +/// that require ServiceManagement calls (launch at login). +/// Uses `@Observable` for SwiftUI integration. @Observable final class SettingsViewModel { // MARK: - Dependencies @@ -46,12 +46,6 @@ final class SettingsViewModel { } } - /// Whether to prevent display sleep (in addition to system sleep). - var preventDisplaySleep: Bool { - get { configuration.preventDisplaySleep } - set { configuration.preventDisplaySleep = newValue } - } - // MARK: - Appearance Settings /// The visual style of the menu bar icon. @@ -72,26 +66,7 @@ final class SettingsViewModel { set { configuration.showRemainingTimeInMenuBar = newValue } } - // MARK: - Dock Icon - - /// Whether the dock icon is visible. - /// The actual activation policy change is deferred to when Settings closes - /// (via onDisappear) to avoid hiding the settings window mid-interaction. - var showDockIcon: Bool = false - - // MARK: - Public Helpers - - /// Applies the dock icon visibility setting by changing the activation policy. - /// Called when the settings window closes, not on every toggle change. - func applyDockIconVisibility() { - if showDockIcon { - NSApp.setActivationPolicy(.regular) - // Reapply the custom icon since macOS resets it on policy change - (NSApp.delegate as? AppDelegate)?.reapplyAppIcon() - } else { - NSApp.setActivationPolicy(.accessory) - } - } + // MARK: - Private Helpers /// Registers or unregisters the app for launch at login via SMAppService. /// diff --git a/Sources/Insomnia/Views/AboutView.swift b/Sources/Insomnia/Views/AboutView.swift index beb1359..32a0864 100644 --- a/Sources/Insomnia/Views/AboutView.swift +++ b/Sources/Insomnia/Views/AboutView.swift @@ -67,12 +67,11 @@ struct AboutView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" } - /// Loads the app icon directly from the cached icon on the AppDelegate. - /// Falls back to searching Resources/AppIcon.icns from the working directory. + /// Loads the app icon from the application icon image or Resources directory. static func loadIcon() -> NSImage? { - // Try the cached icon from AppDelegate first - if let cached = (NSApp.delegate as? AppDelegate)?.cachedAppIcon { - return cached + // Try the current application icon first + if let icon = NSApp.applicationIconImage { + return icon } // Try from current working directory let cwdPath = FileManager.default.currentDirectoryPath + "/Resources/AppIcon.icns" diff --git a/Sources/Insomnia/Views/MenuBarView.swift b/Sources/Insomnia/Views/MenuBarView.swift index 2b85ba1..fb068f8 100644 --- a/Sources/Insomnia/Views/MenuBarView.swift +++ b/Sources/Insomnia/Views/MenuBarView.swift @@ -159,13 +159,10 @@ struct MenuBarView: View { // MARK: - Helpers - /// Switches to regular activation policy, opens a window, reapplies the app icon, - /// and activates the app so the window appears in front. + /// Opens a window and activates the app so the window appears in front. + /// The app stays in accessory mode (no dock icon) since LSUIElement is set. private func activateAndOpen(windowId: String) { - NSApp.setActivationPolicy(.regular) openWindow(id: windowId) - // Reapply the cached icon since macOS resets it when switching activation policies - (NSApp.delegate as? AppDelegate)?.reapplyAppIcon() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { NSApplication.shared.activate(ignoringOtherApps: true) } diff --git a/Sources/Insomnia/Views/SettingsView.swift b/Sources/Insomnia/Views/SettingsView.swift index 4cb92fa..05d0a85 100644 --- a/Sources/Insomnia/Views/SettingsView.swift +++ b/Sources/Insomnia/Views/SettingsView.swift @@ -1,7 +1,7 @@ // SettingsView.swift — Insomnia GUI // -// Form-based settings window with sections for General, Appearance, and -// Dock Icon preferences. Binds to SettingsViewModel which wraps the +// Form-based settings window with sections for General and Appearance +// preferences. Binds to SettingsViewModel which wraps the // InsomniaConfiguration and handles side effects. import SwiftUI @@ -9,10 +9,9 @@ import InsomniaCore /// The settings window content, presented via the SwiftUI `Settings` scene. /// -/// Organized into three sections: -/// - **General**: Launch at login and display sleep prevention +/// Organized into two sections: +/// - **General**: Launch at login /// - **Appearance**: Icon style, visibility, and remaining time display -/// - **Dock Icon**: Toggle dock icon visibility struct SettingsView: View { // MARK: - Dependencies @@ -28,30 +27,18 @@ struct SettingsView: View { // Visual appearance preferences appearanceSection - - // Dock icon visibility control - dockIconSection } .formStyle(.grouped) - .frame(width: 450, height: 350) - .onDisappear { - // Apply the dock icon setting now that the window is closing - // This is deferred from the toggle to avoid hiding the window mid-interaction - viewModel.applyDockIconVisibility() - } + .frame(width: 450, height: 300) } // MARK: - General Section - /// Settings for app startup and sleep prevention mode. + /// Settings for app startup behavior. private var generalSection: some View { Section("General") { // Toggle for automatic launch at system login Toggle("Launch at login", isOn: $viewModel.launchAtLogin) - - // Toggle for preventing display sleep vs. just system sleep - Toggle("Prevent display sleep", isOn: $viewModel.preventDisplaySleep) - .help("When enabled, prevents both display and system sleep. When disabled, only prevents system sleep.") } } @@ -67,15 +54,4 @@ struct SettingsView: View { Toggle("Show remaining time in menu bar", isOn: $viewModel.showRemainingTimeInMenuBar) } } - - // MARK: - Dock Icon Section - - /// Settings for controlling dock icon visibility. - private var dockIconSection: some View { - Section("Dock Icon") { - // Toggle between regular (visible) and accessory (hidden) activation policy - Toggle("Show dock icon", isOn: $viewModel.showDockIcon) - .help("When enabled, the app appears in the Dock. When disabled, it runs as a menu bar-only app.") - } - } } diff --git a/Sources/InsomniaCLI/Commands/CaffeinateCommand.swift b/Sources/InsomniaCLI/Commands/CaffeinateCommand.swift index b4cf2f2..6088099 100644 --- a/Sources/InsomniaCLI/Commands/CaffeinateCommand.swift +++ b/Sources/InsomniaCLI/Commands/CaffeinateCommand.swift @@ -1,9 +1,9 @@ // CaffeinateCommand.swift — InsomniaCLI // // Implements the `insomnia caffeinate` subcommand for starting indefinite -// caffeination. Supports a --display flag to also prevent display sleep. -// Operates in two modes: IPC mode (sends command to GUI) and standalone -// mode (manages power assertions directly when GUI is not running). +// caffeination. Operates in two modes: IPC mode (sends command to GUI) +// and standalone mode (manages power assertions directly when GUI is +// not running). Always prevents both display and system sleep. import ArgumentParser import Foundation @@ -23,15 +23,6 @@ struct CaffeinateCommand: ParsableCommand { abstract: "Start indefinite caffeination" ) - // MARK: - Options - - /// When set, also prevents display sleep in addition to system sleep. - /// - /// Without this flag, only system sleep is prevented and the display - /// may still turn off after the idle timeout. - @Flag(name: .long, help: "Also prevent display sleep") - var display: Bool = false - // MARK: - Execution /// Runs the caffeinate command. @@ -61,15 +52,10 @@ struct CaffeinateCommand: ParsableCommand { /// Creates a PowerAssertionManager, starts an indefinite power assertion, /// installs a SIGINT handler for clean shutdown, and blocks on the run loop. private func runStandalone() throws { - // Determine the assertion type based on the --display flag - let assertionType: PowerAssertionType = display - ? .preventUserIdleDisplaySleep - : .preventUserIdleSystemSleep - // Create the power assertion manager with real IOKit provider let manager = PowerAssertionManager() - // Start the indefinite caffeination assertion - try manager.caffeinate(type: assertionType) + // Start the indefinite caffeination assertion (prevents display + system sleep) + try manager.caffeinate() // Display the standalone mode banner with current state CLIOutput.printStandalone(state: manager.state) diff --git a/Sources/InsomniaCLI/Commands/CaffeinateForCommand.swift b/Sources/InsomniaCLI/Commands/CaffeinateForCommand.swift index 3d75c75..6b5e609 100644 --- a/Sources/InsomniaCLI/Commands/CaffeinateForCommand.swift +++ b/Sources/InsomniaCLI/Commands/CaffeinateForCommand.swift @@ -31,10 +31,6 @@ struct CaffeinateForCommand: ParsableCommand { @Argument(help: "Duration to caffeinate (e.g., 30m, 2h, 1h30m, 90s)") var duration: String - /// When set, also prevents display sleep in addition to system sleep. - @Flag(name: .long, help: "Also prevent display sleep") - var display: Bool = false - // MARK: - Execution /// Runs the caffeinate-for command. @@ -69,15 +65,10 @@ struct CaffeinateForCommand: ParsableCommand { /// /// - Parameter duration: The parsed duration option specifying how long to caffeinate. private func runStandalone(duration: DurationOption) throws { - // Determine the assertion type based on the --display flag - let assertionType: PowerAssertionType = display - ? .preventUserIdleDisplaySleep - : .preventUserIdleSystemSleep - // Create the power assertion manager with real IOKit provider let manager = PowerAssertionManager() - // Start the timed caffeination assertion - try manager.caffeinate(for: duration.timeInterval, type: assertionType) + // Start the timed caffeination assertion (prevents display + system sleep) + try manager.caffeinate(for: duration.timeInterval) // Display the standalone mode banner with current state CLIOutput.printStandalone(state: manager.state) diff --git a/Sources/InsomniaCore/Models/InsomniaConfiguration.swift b/Sources/InsomniaCore/Models/InsomniaConfiguration.swift index 8802beb..9b2a5bf 100644 --- a/Sources/InsomniaCore/Models/InsomniaConfiguration.swift +++ b/Sources/InsomniaCore/Models/InsomniaConfiguration.swift @@ -43,8 +43,6 @@ private enum ConfigKeys { static var iconStyle: String { "\(BuildEnvironment.defaultsPrefix)iconStyle" } /// Whether to show remaining time in the menu bar title. static var showRemainingTimeInMenuBar: String { "\(BuildEnvironment.defaultsPrefix)showRemainingTimeInMenuBar" } - /// Whether to prevent display sleep (vs. only system sleep). - static var preventDisplaySleep: String { "\(BuildEnvironment.defaultsPrefix)preventDisplaySleep" } /// Whether to launch the app at system login. static var launchAtLogin: String { "\(BuildEnvironment.defaultsPrefix)launchAtLogin" } /// JSON-encoded array of schedule rules. @@ -89,15 +87,6 @@ public final class InsomniaConfiguration { } } - /// Whether to prevent display sleep in addition to system sleep. - /// When `true`, uses `preventUserIdleDisplaySleep` instead of system sleep. - public var preventDisplaySleep: Bool { - didSet { - // Persist the updated value to UserDefaults - defaults.set(preventDisplaySleep, forKey: ConfigKeys.preventDisplaySleep) - } - } - /// Whether the application should launch automatically at system login. public var launchAtLogin: Bool { didSet { @@ -131,7 +120,6 @@ public final class InsomniaConfiguration { self.iconStyle = .default } self.showRemainingTimeInMenuBar = defaults.bool(forKey: ConfigKeys.showRemainingTimeInMenuBar) - self.preventDisplaySleep = defaults.bool(forKey: ConfigKeys.preventDisplaySleep) self.launchAtLogin = defaults.bool(forKey: ConfigKeys.launchAtLogin) // Load schedule rules from JSON data self.scheduleRules = InsomniaConfiguration.loadScheduleRules(from: defaults) @@ -179,12 +167,10 @@ public final class InsomniaConfiguration { // MARK: - Convenience - /// The preferred assertion type based on the current configuration. + /// The assertion type used for all caffeination modes. /// - /// Returns display sleep prevention if `preventDisplaySleep` is enabled, - /// otherwise returns system sleep prevention. + /// Always prevents display sleep (which implicitly prevents system sleep). public var preferredAssertionType: PowerAssertionType { - // Choose assertion type based on the display sleep setting - return preventDisplaySleep ? .preventUserIdleDisplaySleep : .preventUserIdleSystemSleep + return .preventUserIdleDisplaySleep } } diff --git a/Sources/InsomniaCore/Power/PowerAssertionManager.swift b/Sources/InsomniaCore/Power/PowerAssertionManager.swift index 6007abb..743375b 100644 --- a/Sources/InsomniaCore/Power/PowerAssertionManager.swift +++ b/Sources/InsomniaCore/Power/PowerAssertionManager.swift @@ -160,7 +160,7 @@ public final class PowerAssertionManager { /// /// - Parameter type: The type of sleep to prevent. Defaults to preventing system sleep. /// - Throws: If the IOKit assertion cannot be created. - public func caffeinate(type: PowerAssertionType = .preventUserIdleSystemSleep) throws { + public func caffeinate(type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { // Release any existing assertion before creating a new one try releaseExistingAssertion() // Create the new assertion via the provider @@ -183,7 +183,7 @@ public final class PowerAssertionManager { /// - duration: The duration in seconds to keep the assertion active. /// - type: The type of sleep to prevent. Defaults to preventing system sleep. /// - Throws: If the IOKit assertion cannot be created. - public func caffeinate(for duration: TimeInterval, type: PowerAssertionType = .preventUserIdleSystemSleep) throws { + public func caffeinate(for duration: TimeInterval, type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { // Calculate the end date from the duration let endDate = Date().addingTimeInterval(duration) // Delegate to the until-date method @@ -199,7 +199,7 @@ public final class PowerAssertionManager { /// - date: The date at which to automatically decaffeinate. /// - type: The type of sleep to prevent. Defaults to preventing system sleep. /// - Throws: If the IOKit assertion cannot be created. - public func caffeinate(until date: Date, type: PowerAssertionType = .preventUserIdleSystemSleep) throws { + public func caffeinate(until date: Date, type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { // Release any existing assertion before creating a new one try releaseExistingAssertion() // Create the new assertion via the provider @@ -222,7 +222,7 @@ public final class PowerAssertionManager { /// /// - Parameter type: The assertion type to use when toggling on. Defaults to system sleep. /// - Throws: If creating or releasing the assertion fails. - public func toggle(type: PowerAssertionType = .preventUserIdleSystemSleep) throws { + public func toggle(type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { if state.isActive { // Currently active — turn off try decaffeinate() @@ -258,7 +258,7 @@ public final class PowerAssertionManager { public func caffeinateWhileRunning( bundleIdentifier: String, appName: String, - type: PowerAssertionType = .preventUserIdleSystemSleep + type: PowerAssertionType = .preventUserIdleDisplaySleep ) throws { // Release any existing assertion before creating a new one try releaseExistingAssertion() diff --git a/Sources/InsomniaCore/Scheduling/CaffeinationScheduler.swift b/Sources/InsomniaCore/Scheduling/CaffeinationScheduler.swift index abef7d6..b58f525 100644 --- a/Sources/InsomniaCore/Scheduling/CaffeinationScheduler.swift +++ b/Sources/InsomniaCore/Scheduling/CaffeinationScheduler.swift @@ -57,11 +57,11 @@ public final class CaffeinationScheduler { /// /// - Parameters: /// - duration: The duration option specifying how long to caffeinate. - /// - type: The assertion type. Defaults to preventing system sleep. + /// - type: The assertion type. Defaults to preventing display sleep. /// - Throws: If the power assertion cannot be created. public func startTimed( _ duration: DurationOption, - type: PowerAssertionType = .preventUserIdleSystemSleep + type: PowerAssertionType = .preventUserIdleDisplaySleep ) throws { // Mark as user-initiated (not schedule-activated) isScheduleActivated = false @@ -75,11 +75,11 @@ public final class CaffeinationScheduler { /// /// - Parameters: /// - date: The date at which caffeination should automatically end. - /// - type: The assertion type. Defaults to preventing system sleep. + /// - type: The assertion type. Defaults to preventing display sleep. /// - Throws: If the power assertion cannot be created. public func startUntil( _ date: Date, - type: PowerAssertionType = .preventUserIdleSystemSleep + type: PowerAssertionType = .preventUserIdleDisplaySleep ) throws { // Mark as user-initiated isScheduleActivated = false @@ -98,12 +98,12 @@ public final class CaffeinationScheduler { /// - Parameters: /// - bundleIdentifier: The bundle ID of the app to watch (e.g., "com.apple.Xcode"). /// - appName: The display name of the app (used in status messages). - /// - type: The assertion type. Defaults to preventing system sleep. + /// - type: The assertion type. Defaults to preventing display sleep. /// - Throws: If the power assertion cannot be created. public func startWhileAppRunning( bundleIdentifier: String, appName: String? = nil, - type: PowerAssertionType = .preventUserIdleSystemSleep + type: PowerAssertionType = .preventUserIdleDisplaySleep ) throws { // Mark as user-initiated isScheduleActivated = false diff --git a/Sources/InsomniaCore/Scheduling/ScheduleRule.swift b/Sources/InsomniaCore/Scheduling/ScheduleRule.swift index ffd4242..1afbacc 100644 --- a/Sources/InsomniaCore/Scheduling/ScheduleRule.swift +++ b/Sources/InsomniaCore/Scheduling/ScheduleRule.swift @@ -85,14 +85,14 @@ public struct ScheduleRule: Codable, Identifiable, Equatable, Sendable { /// - startTime: The start time (hour/minute) of the caffeination window. /// - endTime: The end time (hour/minute) of the caffeination window. /// - isEnabled: Whether the rule is active. Defaults to `true`. - /// - assertionType: The assertion type. Defaults to preventing system sleep. + /// - assertionType: The assertion type. Defaults to preventing display sleep. public init( id: UUID = UUID(), weekdays: Set, startTime: DateComponents, endTime: DateComponents, isEnabled: Bool = true, - assertionType: PowerAssertionType = .preventUserIdleSystemSleep + assertionType: PowerAssertionType = .preventUserIdleDisplaySleep ) { self.id = id self.weekdays = weekdays diff --git a/Tests/InsomniaCoreTests/PowerAssertionManagerTests.swift b/Tests/InsomniaCoreTests/PowerAssertionManagerTests.swift index 5c18f0b..c857a04 100644 --- a/Tests/InsomniaCoreTests/PowerAssertionManagerTests.swift +++ b/Tests/InsomniaCoreTests/PowerAssertionManagerTests.swift @@ -111,18 +111,11 @@ final class PowerAssertionManagerTests: XCTestCase { // Verify state transition XCTAssertEqual(manager.state, .caffeinatedIndefinitely) XCTAssertTrue(manager.state.isActive) - XCTAssertEqual(manager.currentAssertionType, .preventUserIdleSystemSleep) + XCTAssertEqual(manager.currentAssertionType, .preventUserIdleDisplaySleep) // Verify the mock was called XCTAssertEqual(mockProvider.createCallCount, 1) XCTAssertEqual(mockProvider.activeAssertions.count, 1) - } - - /// Tests caffeination with display sleep prevention type. - func testCaffeinateWithDisplaySleepType() throws { - // Caffeinate with display sleep type - try manager.caffeinate(type: .preventUserIdleDisplaySleep) - // Verify the correct assertion type was used - XCTAssertEqual(manager.currentAssertionType, .preventUserIdleDisplaySleep) + // Verify the correct IOKit assertion type was used XCTAssertEqual(mockProvider.lastAssertionType, PowerAssertionType.preventUserIdleDisplaySleep.iokitAssertionType) } diff --git a/Tests/InsomniaIntegrationTests/PowerAssertionIntegrationTests.swift b/Tests/InsomniaIntegrationTests/PowerAssertionIntegrationTests.swift index ad28519..81c4b55 100644 --- a/Tests/InsomniaIntegrationTests/PowerAssertionIntegrationTests.swift +++ b/Tests/InsomniaIntegrationTests/PowerAssertionIntegrationTests.swift @@ -39,7 +39,7 @@ final class PowerAssertionIntegrationTests: XCTestCase { try manager.caffeinate() XCTAssertEqual(manager.state, .caffeinatedIndefinitely) XCTAssertTrue(manager.state.isActive) - XCTAssertEqual(manager.currentAssertionType, .preventUserIdleSystemSleep) + XCTAssertEqual(manager.currentAssertionType, .preventUserIdleDisplaySleep) } /// Tests that decaffeinate releases the real IOKit assertion. From 471770b10f5c813b4a67650aca9417440ef4df7d Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Fri, 3 Apr 2026 10:42:11 +1000 Subject: [PATCH 2/4] Add auto-update checker with menu bar badge indicator - Check GitHub Releases API for new versions (hourly + manual) - Show download arrow in menu bar when update available - Download DMG to ~/Downloads and mount for drag-install - AppVersion model in InsomniaCore for version parsing/comparison - 16 new tests for version parsing and comparison logic - Skip periodic checks in dev builds Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: GitButler --- Sources/Insomnia/AppDelegate.swift | 10 + Sources/Insomnia/InsomniaApp.swift | 9 +- Sources/Insomnia/Update/GitHubRelease.swift | 41 ++++ Sources/Insomnia/Update/UpdateChecker.swift | 229 ++++++++++++++++++ Sources/Insomnia/Views/MenuBarView.swift | 36 ++- Sources/InsomniaCore/Models/AppVersion.swift | 78 ++++++ Tests/InsomniaCoreTests/AppVersionTests.swift | 127 ++++++++++ 7 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 Sources/Insomnia/Update/GitHubRelease.swift create mode 100644 Sources/Insomnia/Update/UpdateChecker.swift create mode 100644 Sources/InsomniaCore/Models/AppVersion.swift create mode 100644 Tests/InsomniaCoreTests/AppVersionTests.swift diff --git a/Sources/Insomnia/AppDelegate.swift b/Sources/Insomnia/AppDelegate.swift index 452d09e..363502e 100644 --- a/Sources/Insomnia/AppDelegate.swift +++ b/Sources/Insomnia/AppDelegate.swift @@ -13,6 +13,7 @@ import InsomniaCore /// - Creates and owns the shared ``CaffeinationScheduler`` used by both /// the GUI and the IPC server /// - Starts the ``IPCServer`` on launch so the CLI can communicate +/// - Starts the ``UpdateChecker`` for periodic GitHub release checks /// - Releases all power assertions and stops the IPC server on termination final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Shared State @@ -24,6 +25,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// The user configuration shared across the application. let configuration = InsomniaConfiguration() + /// The update checker that periodically queries GitHub for new releases. + let updateChecker = UpdateChecker() + // MARK: - Owned Controllers /// The IPC server that receives commands from the CLI. @@ -53,6 +57,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Ensure the app runs as a menu bar-only app (no dock icon) NSApp.setActivationPolicy(.accessory) + + // Start periodic update checks (hourly, with an initial check on launch) + updateChecker.startPeriodicChecks() } /// Called when the application is about to terminate. @@ -67,6 +74,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { print("Failed to cancel caffeination on quit: \(error.localizedDescription)") } + // Stop periodic update checks + updateChecker.stopPeriodicChecks() + // Stop the IPC server and remove the socket file ipcServer?.stop() ipcServer = nil diff --git a/Sources/Insomnia/InsomniaApp.swift b/Sources/Insomnia/InsomniaApp.swift index dc0bda3..9bd8aac 100644 --- a/Sources/Insomnia/InsomniaApp.swift +++ b/Sources/Insomnia/InsomniaApp.swift @@ -32,7 +32,7 @@ struct InsomniaApp: App { MenuBarExtra { // Render the menu content if the view model is ready if let viewModel { - MenuBarView(viewModel: viewModel) + MenuBarView(viewModel: viewModel, updateChecker: appDelegate.updateChecker) } } label: { // Menu bar icon changes based on caffeination state @@ -82,12 +82,17 @@ struct InsomniaApp: App { // MARK: - Menu Bar Label /// The label displayed in the menu bar — an SF Symbol that changes - /// based on the current caffeination state. + /// based on the current caffeination state. Shows a down arrow indicator + /// when an update is available. @ViewBuilder private var menuBarLabel: some View { if let viewModel { // Show coffee cup when awake, moon when sleeping Image(systemName: viewModel.menuBarImage) + // Show a down arrow indicator when an update is available + if appDelegate.updateChecker.isUpdateAvailable { + Text("\u{2B07}") + } } else { // Fallback icon before the view model initializes Image(systemName: "moon.zzz") diff --git a/Sources/Insomnia/Update/GitHubRelease.swift b/Sources/Insomnia/Update/GitHubRelease.swift new file mode 100644 index 0000000..aa3d060 --- /dev/null +++ b/Sources/Insomnia/Update/GitHubRelease.swift @@ -0,0 +1,41 @@ +// GitHubRelease.swift — Insomnia GUI +// +// Codable model for the GitHub Releases API response. Only decodes the +// fields needed for update checking: the release tag, page URL, and +// downloadable asset information. + +import Foundation + +/// Represents a GitHub release from the Releases API. +/// +/// Used to decode the response from +/// `https://api.github.com/repos/gordonbeeming/insomnia/releases/latest`. +/// Only the fields needed for update checking are included. +struct GitHubRelease: Codable { + /// The git tag for this release (e.g., "v0.6"). + let tagName: String + + /// The URL of the release page on GitHub. + let htmlUrl: String + + /// The downloadable assets attached to this release. + let assets: [Asset] + + /// A single downloadable file attached to a GitHub release. + struct Asset: Codable { + /// The filename of the asset (e.g., "Insomnia-0.6.dmg"). + let name: String + + /// The direct download URL for the asset. + let browserDownloadUrl: String + } + + /// Finds the DMG asset for the macOS GUI application. + /// + /// Searches the assets list for a file ending in `.dmg`. + /// - Returns: The DMG asset if found, or `nil` if no DMG is attached. + var dmgAsset: Asset? { + // Look for the DMG file in the release assets + return assets.first { $0.name.hasSuffix(".dmg") } + } +} diff --git a/Sources/Insomnia/Update/UpdateChecker.swift b/Sources/Insomnia/Update/UpdateChecker.swift new file mode 100644 index 0000000..84e07b1 --- /dev/null +++ b/Sources/Insomnia/Update/UpdateChecker.swift @@ -0,0 +1,229 @@ +// UpdateChecker.swift — Insomnia GUI +// +// Checks for application updates by querying the GitHub Releases API. +// Manages periodic background checks, version comparison, and DMG +// download for user-initiated updates. Uses @Observable for SwiftUI +// integration with the menu bar UI. + +import AppKit +import Foundation +import InsomniaCore + +/// Manages update checking against GitHub Releases. +/// +/// Periodically queries the GitHub API for the latest release, compares +/// the version with the running app, and provides download functionality. +/// All state properties are observable for automatic SwiftUI view updates. +@Observable +@MainActor +final class UpdateChecker { + // MARK: - Constants + + /// The GitHub Releases API endpoint for the latest release. + private static let releasesURL = URL( + string: "https://api.github.com/repos/gordonbeeming/insomnia/releases/latest" + )! + + /// Minimum interval between checks to avoid API rate limiting (5 minutes). + private static let minimumCheckInterval: TimeInterval = 300 + + /// Interval between automatic background checks (1 hour). + private static let periodicCheckInterval: TimeInterval = 3600 + + // MARK: - Observable State + + /// Whether a newer version is available on GitHub. + private(set) var isUpdateAvailable: Bool = false + + /// The version string of the latest release (e.g., "0.6"). + private(set) var latestVersion: String? + + /// The direct download URL for the latest DMG. + private(set) var downloadURL: URL? + + /// Whether an update check is currently in progress. + private(set) var isChecking: Bool = false + + /// Whether a DMG download is currently in progress. + private(set) var isDownloading: Bool = false + + /// Error message from the last failed operation, if any. + private(set) var lastError: String? + + // MARK: - Private State + + /// Timer for periodic background update checks. + private var timer: Timer? + + /// Timestamp of the last successful or attempted check. + private var lastCheckDate: Date? + + // MARK: - Initialization + + /// Creates an update checker. Safe to call from any context. + nonisolated init() {} + + // MARK: - Periodic Checks + + /// Starts periodic background update checks every hour. + /// + /// Also fires an immediate check on startup. In dev builds, periodic + /// checks are disabled to avoid unnecessary API calls during development. + func startPeriodicChecks() { + // Skip periodic checks in dev builds + guard !BuildEnvironment.isDev else { return } + // Fire an initial check after a short startup delay + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + guard let self else { return } + Task { await self.checkForUpdate() } + } + // Schedule the repeating timer for hourly checks + timer = Timer.scheduledTimer( + withTimeInterval: Self.periodicCheckInterval, + repeats: true + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.checkForUpdate() + } + } + } + + /// Stops periodic background update checks. + func stopPeriodicChecks() { + timer?.invalidate() + timer = nil + } + + // MARK: - Update Check + + /// Checks for a newer version by querying the GitHub Releases API. + /// + /// Compares the latest release tag with the running app's bundle version. + /// Updates all observable state properties based on the result. + /// Skips the check if one was performed within the last 5 minutes. + /// + /// - Parameter force: If `true`, ignores the minimum check interval. + func checkForUpdate(force: Bool = false) async { + // Rate limit: skip if checked recently (unless forced) + if !force, let lastCheck = lastCheckDate, + Date().timeIntervalSince(lastCheck) < Self.minimumCheckInterval { + return + } + // Don't start a new check if one is already running + guard !isChecking else { return } + isChecking = true + lastError = nil + defer { isChecking = false } + // Record the check time + lastCheckDate = Date() + do { + // Fetch the latest release from GitHub + let release = try await fetchLatestRelease() + // Parse the remote version from the tag + guard let remoteVersion = AppVersion(string: release.tagName) else { + lastError = "Could not parse release tag: \(release.tagName)" + return + } + // Parse the local version from the app bundle + let localVersionString = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "0.0.0" + guard let localVersion = AppVersion(string: localVersionString) else { + lastError = "Could not parse local version: \(localVersionString)" + return + } + // Compare versions — update available if remote is newer + if remoteVersion > localVersion { + isUpdateAvailable = true + latestVersion = remoteVersion.description + // Extract the DMG download URL from the release assets + if let dmg = release.dmgAsset, + let url = URL(string: dmg.browserDownloadUrl) { + downloadURL = url + } + } else { + isUpdateAvailable = false + latestVersion = nil + downloadURL = nil + } + } catch { + lastError = error.localizedDescription + } + } + + // MARK: - Download and Install + + /// Downloads the latest DMG and opens it for the user to install. + /// + /// The DMG is saved to `~/Downloads` and automatically mounted + /// via `NSWorkspace` so the user can drag the app to Applications. + func downloadAndInstall() async { + guard let url = downloadURL else { + lastError = "No download URL available" + return + } + guard !isDownloading else { return } + isDownloading = true + lastError = nil + defer { isDownloading = false } + do { + // Download the DMG to a temporary location + let (tempURL, _) = try await URLSession.shared.download(from: url) + // Build the destination path in ~/Downloads + let fileName = "Insomnia-\(latestVersion ?? "latest").dmg" + let downloadsDir = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first! + let destinationURL = downloadsDir.appendingPathComponent(fileName) + // Remove any existing file at the destination + try? FileManager.default.removeItem(at: destinationURL) + // Move the downloaded file to ~/Downloads + try FileManager.default.moveItem(at: tempURL, to: destinationURL) + // Open the DMG — macOS will mount it and show the Finder window + NSWorkspace.shared.open(destinationURL) + } catch { + lastError = "Download failed: \(error.localizedDescription)" + } + } + + // MARK: - Private Helpers + + /// Fetches the latest release from the GitHub Releases API. + /// + /// - Returns: The parsed `GitHubRelease` model. + /// - Throws: If the network request or JSON decoding fails. + private func fetchLatestRelease() async throws -> GitHubRelease { + // Build the request with a User-Agent header (required by GitHub API) + var request = URLRequest(url: Self.releasesURL) + request.setValue("Insomnia-macOS", forHTTPHeaderField: "User-Agent") + // Perform the network request + let (data, response) = try await URLSession.shared.data(for: request) + // Validate the HTTP status code + if let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode != 200 { + throw UpdateError.httpError(statusCode: httpResponse.statusCode) + } + // Decode the JSON response using snake_case conversion + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(GitHubRelease.self, from: data) + } +} + +// MARK: - Update Errors + +/// Errors specific to the update checking process. +enum UpdateError: Error, LocalizedError { + /// The GitHub API returned a non-200 status code. + case httpError(statusCode: Int) + + /// Human-readable error description. + var errorDescription: String? { + switch self { + case .httpError(let code): + return "GitHub API returned status \(code)" + } + } +} diff --git a/Sources/Insomnia/Views/MenuBarView.swift b/Sources/Insomnia/Views/MenuBarView.swift index fb068f8..6b123ad 100644 --- a/Sources/Insomnia/Views/MenuBarView.swift +++ b/Sources/Insomnia/Views/MenuBarView.swift @@ -19,6 +19,9 @@ struct MenuBarView: View { /// The view model driving all state and actions. @Bindable var viewModel: MenuBarViewModel + /// The update checker for showing update status and triggering checks. + var updateChecker: UpdateChecker + /// Environment action to open named windows (About, Duration Picker, etc.). @Environment(\.openWindow) private var openWindow @@ -137,7 +140,7 @@ struct MenuBarView: View { // MARK: - Footer Section - /// Settings, About, and Quit menu items. + /// Settings, About, Update, and Quit menu items. private var footerSection: some View { Group { Button("Settings...") { @@ -150,6 +153,11 @@ struct MenuBarView: View { Divider() + // Update section — shows contextual status and actions + updateSection + + Divider() + Button("Quit \(BuildEnvironment.appName)") { // Terminate the application NSApplication.shared.terminate(nil) @@ -157,6 +165,32 @@ struct MenuBarView: View { } } + // MARK: - Update Section + + /// Update check status and action buttons. + @ViewBuilder + private var updateSection: some View { + if updateChecker.isUpdateAvailable, let version = updateChecker.latestVersion { + // An update is available — show download button + if updateChecker.isDownloading { + Text("Downloading v\(version)...") + } else { + Button("Download v\(version)") { + Task { await updateChecker.downloadAndInstall() } + } + } + } + if updateChecker.isChecking { + // A check is in progress + Text("Checking for updates...") + } else { + // Manual check button + Button("Check for Updates") { + Task { await updateChecker.checkForUpdate(force: true) } + } + } + } + // MARK: - Helpers /// Opens a window and activates the app so the window appears in front. diff --git a/Sources/InsomniaCore/Models/AppVersion.swift b/Sources/InsomniaCore/Models/AppVersion.swift new file mode 100644 index 0000000..6c8f703 --- /dev/null +++ b/Sources/InsomniaCore/Models/AppVersion.swift @@ -0,0 +1,78 @@ +// AppVersion.swift — InsomniaCore +// +// Parses and compares application version strings. Handles both GitHub +// release tag formats ("v0.6") and bundle version formats ("0.5.42") +// by extracting only the major and minor components for comparison. + +import Foundation + +/// Represents an application version with major and minor components. +/// +/// Supports parsing from multiple formats: +/// - GitHub release tags: `"v0.6"`, `"v1.2"` +/// - Bundle versions: `"0.5.42"`, `"1.0.0"` +/// - Simple versions: `"0.6"`, `"1.2"` +/// +/// The build number (third component) is intentionally ignored since +/// release tags only use major.minor versioning. +public struct AppVersion: Comparable, Equatable, CustomStringConvertible { + /// The major version component (e.g., 0 in "0.6"). + public let major: Int + + /// The minor version component (e.g., 6 in "0.6"). + public let minor: Int + + // MARK: - Initialization + + /// Creates an AppVersion from major and minor components. + /// + /// - Parameters: + /// - major: The major version number. + /// - minor: The minor version number. + public init(major: Int, minor: Int) { + self.major = major + self.minor = minor + } + + /// Parses a version string into an AppVersion. + /// + /// Strips a leading "v" prefix if present, then extracts the first + /// two dot-separated numeric components. Any third component (build + /// number) is ignored. + /// + /// - Parameter string: The version string to parse (e.g., "v0.6", "0.5.42"). + /// - Returns: An `AppVersion` if parsing succeeds, or `nil` for invalid input. + public init?(string: String) { + // Strip the "v" prefix used in GitHub release tags + var cleaned = string + if cleaned.hasPrefix("v") { + cleaned = String(cleaned.dropFirst()) + } + // Split on dots and take the first two numeric components + let parts = cleaned.split(separator: ".") + guard parts.count >= 2, + let major = Int(parts[0]), + let minor = Int(parts[1]) else { + return nil + } + self.major = major + self.minor = minor + } + + // MARK: - Comparable + + /// Compares two versions by major first, then minor. + public static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { + if lhs.major != rhs.major { + return lhs.major < rhs.major + } + return lhs.minor < rhs.minor + } + + // MARK: - CustomStringConvertible + + /// A display-friendly version string (e.g., "0.6"). + public var description: String { + return "\(major).\(minor)" + } +} diff --git a/Tests/InsomniaCoreTests/AppVersionTests.swift b/Tests/InsomniaCoreTests/AppVersionTests.swift new file mode 100644 index 0000000..abba894 --- /dev/null +++ b/Tests/InsomniaCoreTests/AppVersionTests.swift @@ -0,0 +1,127 @@ +// AppVersionTests.swift — InsomniaCoreTests +// +// Tests for AppVersion parsing and comparison, covering GitHub release +// tag formats, bundle version formats, edge cases, and ordering. + +import XCTest +@testable import InsomniaCore + +/// Tests for the AppVersion model. +final class AppVersionTests: XCTestCase { + + // MARK: - Parsing + + /// Tests parsing a GitHub release tag with "v" prefix. + func testParseTagWithPrefix() { + let version = AppVersion(string: "v0.6") + XCTAssertNotNil(version) + XCTAssertEqual(version?.major, 0) + XCTAssertEqual(version?.minor, 6) + } + + /// Tests parsing a version string without "v" prefix. + func testParseWithoutPrefix() { + let version = AppVersion(string: "0.5") + XCTAssertNotNil(version) + XCTAssertEqual(version?.major, 0) + XCTAssertEqual(version?.minor, 5) + } + + /// Tests parsing a bundle version with build number (third component ignored). + func testParseBundleVersion() { + let version = AppVersion(string: "0.5.42") + XCTAssertNotNil(version) + XCTAssertEqual(version?.major, 0) + XCTAssertEqual(version?.minor, 5) + } + + /// Tests parsing a tagged bundle version with "v" prefix and build number. + func testParseTaggedBundleVersion() { + let version = AppVersion(string: "v1.2.100") + XCTAssertNotNil(version) + XCTAssertEqual(version?.major, 1) + XCTAssertEqual(version?.minor, 2) + } + + /// Tests parsing a higher major version. + func testParseHigherMajor() { + let version = AppVersion(string: "v2.0") + XCTAssertNotNil(version) + XCTAssertEqual(version?.major, 2) + XCTAssertEqual(version?.minor, 0) + } + + // MARK: - Invalid Input + + /// Tests that empty string returns nil. + func testParseEmptyString() { + XCTAssertNil(AppVersion(string: "")) + } + + /// Tests that a single number returns nil (needs at least major.minor). + func testParseSingleNumber() { + XCTAssertNil(AppVersion(string: "5")) + } + + /// Tests that garbage input returns nil. + func testParseGarbage() { + XCTAssertNil(AppVersion(string: "not-a-version")) + } + + /// Tests that "v" alone returns nil. + func testParsePrefixOnly() { + XCTAssertNil(AppVersion(string: "v")) + } + + /// Tests that non-numeric components return nil. + func testParseNonNumeric() { + XCTAssertNil(AppVersion(string: "v1.abc")) + } + + // MARK: - Comparison + + /// Tests that a higher minor version is greater. + func testHigherMinorIsGreater() { + let v05 = AppVersion(string: "0.5")! + let v06 = AppVersion(string: "0.6")! + XCTAssertTrue(v06 > v05) + XCTAssertFalse(v05 > v06) + } + + /// Tests that a higher major version is greater regardless of minor. + func testHigherMajorIsGreater() { + let v099 = AppVersion(string: "0.99")! + let v10 = AppVersion(string: "1.0")! + XCTAssertTrue(v10 > v099) + } + + /// Tests that equal versions are equal. + func testEqualVersions() { + let a = AppVersion(string: "0.5")! + let b = AppVersion(string: "v0.5")! + XCTAssertEqual(a, b) + XCTAssertFalse(a < b) + XCTAssertFalse(a > b) + } + + /// Tests that bundle version and tag version with same major.minor are equal. + func testBundleVersionEqualsTag() { + let bundle = AppVersion(string: "0.5.42")! + let tag = AppVersion(string: "v0.5")! + XCTAssertEqual(bundle, tag) + } + + // MARK: - Description + + /// Tests the string description format. + func testDescription() { + let version = AppVersion(string: "v0.6")! + XCTAssertEqual(version.description, "0.6") + } + + /// Tests description for higher versions. + func testDescriptionHigherVersion() { + let version = AppVersion(string: "12.34.56")! + XCTAssertEqual(version.description, "12.34") + } +} From 2ad3f24aa411f0d490d4e06ffda0c88cbf4f9104 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Fri, 3 Apr 2026 10:50:10 +1000 Subject: [PATCH 3/4] Fix strict concurrency errors and add CI build instructions Remove @MainActor from UpdateChecker to fix actor-isolation errors in Swift 5.10 strict concurrency mode. Add strict concurrency build command to CLAUDE.md so future changes are tested before pushing. Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: GitButler --- CLAUDE.md | 11 +++++++---- Sources/Insomnia/Update/UpdateChecker.swift | 8 +------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3283c81..66b67f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,15 @@ Three targets in `Package.swift`: ## Build & Test ```bash -swift build # debug build -swift test # 118+ tests -swift run Insomnia # run GUI locally -swift run InsomniaCLI status # run CLI +swift build # debug build +swift build -Xswiftc -strict-concurrency=complete # build with strict concurrency (matches CI) +swift test # 133+ tests +swift run Insomnia # run GUI locally +swift run InsomniaCLI status # run CLI ``` +**Important**: CI runs Swift 5.10 with strict concurrency checking. Always run `swift build -Xswiftc -strict-concurrency=complete` before pushing to catch actor-isolation errors that don't surface in default debug builds. + ## Code Comments 85%+ comment coverage required. Every file needs: diff --git a/Sources/Insomnia/Update/UpdateChecker.swift b/Sources/Insomnia/Update/UpdateChecker.swift index 84e07b1..21566e5 100644 --- a/Sources/Insomnia/Update/UpdateChecker.swift +++ b/Sources/Insomnia/Update/UpdateChecker.swift @@ -15,7 +15,6 @@ import InsomniaCore /// the version with the running app, and provides download functionality. /// All state properties are observable for automatic SwiftUI view updates. @Observable -@MainActor final class UpdateChecker { // MARK: - Constants @@ -58,11 +57,6 @@ final class UpdateChecker { /// Timestamp of the last successful or attempted check. private var lastCheckDate: Date? - // MARK: - Initialization - - /// Creates an update checker. Safe to call from any context. - nonisolated init() {} - // MARK: - Periodic Checks /// Starts periodic background update checks every hour. @@ -83,7 +77,7 @@ final class UpdateChecker { repeats: true ) { [weak self] _ in guard let self else { return } - Task { @MainActor in + Task { await self.checkForUpdate() } } From 3316d84f47d956e54d8a60631f7506c59dc76424 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Fri, 3 Apr 2026 10:59:21 +1000 Subject: [PATCH 4/4] Address PR review feedback - Only set isUpdateAvailable when valid DMG URL exists - Gate download button on downloadURL presence in menu UI - Show lastError in update section for user-visible feedback - Validate download URL host against GitHub allowlist - Move file I/O in downloadAndInstall to background thread - Fix NSApp.applicationIconImage optional binding in AboutView - Update all doc comments from "system sleep" to "display sleep" Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: GitButler --- Sources/Insomnia/Update/UpdateChecker.swift | 58 ++++++++++++++----- Sources/Insomnia/Views/AboutView.swift | 8 +-- Sources/Insomnia/Views/MenuBarView.swift | 12 +++- .../Power/PowerAssertionManager.swift | 10 ++-- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/Sources/Insomnia/Update/UpdateChecker.swift b/Sources/Insomnia/Update/UpdateChecker.swift index 21566e5..3e0574c 100644 --- a/Sources/Insomnia/Update/UpdateChecker.swift +++ b/Sources/Insomnia/Update/UpdateChecker.swift @@ -129,12 +129,17 @@ final class UpdateChecker { } // Compare versions — update available if remote is newer if remoteVersion > localVersion { - isUpdateAvailable = true latestVersion = remoteVersion.description // Extract the DMG download URL from the release assets if let dmg = release.dmgAsset, - let url = URL(string: dmg.browserDownloadUrl) { + let url = URL(string: dmg.browserDownloadUrl), + Self.isAllowedDownloadHost(url) { downloadURL = url + isUpdateAvailable = true + } else { + // Update exists but no valid DMG asset — still notify + downloadURL = nil + isUpdateAvailable = true } } else { isUpdateAvailable = false @@ -152,6 +157,7 @@ final class UpdateChecker { /// /// The DMG is saved to `~/Downloads` and automatically mounted /// via `NSWorkspace` so the user can drag the app to Applications. + /// File I/O is performed off the main thread to avoid UI jank. func downloadAndInstall() async { guard let url = downloadURL else { lastError = "No download URL available" @@ -164,18 +170,23 @@ final class UpdateChecker { do { // Download the DMG to a temporary location let (tempURL, _) = try await URLSession.shared.download(from: url) - // Build the destination path in ~/Downloads - let fileName = "Insomnia-\(latestVersion ?? "latest").dmg" - let downloadsDir = FileManager.default.urls( - for: .downloadsDirectory, - in: .userDomainMask - ).first! - let destinationURL = downloadsDir.appendingPathComponent(fileName) - // Remove any existing file at the destination - try? FileManager.default.removeItem(at: destinationURL) - // Move the downloaded file to ~/Downloads - try FileManager.default.moveItem(at: tempURL, to: destinationURL) - // Open the DMG — macOS will mount it and show the Finder window + // Capture the version string before moving to background + let version = latestVersion ?? "latest" + // Perform file I/O off the main thread + let destinationURL = try await Task.detached { + let fileName = "Insomnia-\(version).dmg" + let downloadsDir = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first! + let destination = downloadsDir.appendingPathComponent(fileName) + // Remove any existing file at the destination + try? FileManager.default.removeItem(at: destination) + // Move the downloaded file to ~/Downloads + try FileManager.default.moveItem(at: tempURL, to: destination) + return destination + }.value + // Open the DMG on the main thread — macOS will mount it NSWorkspace.shared.open(destinationURL) } catch { lastError = "Download failed: \(error.localizedDescription)" @@ -204,6 +215,25 @@ final class UpdateChecker { decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(GitHubRelease.self, from: data) } + + /// Validates that a download URL points to an allowed GitHub host. + /// + /// Only allows HTTPS downloads from GitHub's known asset hosts to prevent + /// downloading from spoofed or malicious URLs in a compromised release. + /// + /// - Parameter url: The download URL to validate. + /// - Returns: `true` if the URL uses HTTPS and points to an allowed host. + private static func isAllowedDownloadHost(_ url: URL) -> Bool { + // Only allow HTTPS downloads + guard url.scheme == "https" else { return false } + // Allowlisted GitHub asset hosts + let allowedHosts = [ + "github.com", + "objects.githubusercontent.com", + ] + guard let host = url.host else { return false } + return allowedHosts.contains(host) + } } // MARK: - Update Errors diff --git a/Sources/Insomnia/Views/AboutView.swift b/Sources/Insomnia/Views/AboutView.swift index 32a0864..d0c7e95 100644 --- a/Sources/Insomnia/Views/AboutView.swift +++ b/Sources/Insomnia/Views/AboutView.swift @@ -69,11 +69,11 @@ struct AboutView: View { /// Loads the app icon from the application icon image or Resources directory. static func loadIcon() -> NSImage? { - // Try the current application icon first - if let icon = NSApp.applicationIconImage { - return icon + // Use the current application icon if the app is running + if let app = NSApp { + return app.applicationIconImage } - // Try from current working directory + // Try from current working directory as fallback let cwdPath = FileManager.default.currentDirectoryPath + "/Resources/AppIcon.icns" return NSImage(contentsOfFile: cwdPath) } diff --git a/Sources/Insomnia/Views/MenuBarView.swift b/Sources/Insomnia/Views/MenuBarView.swift index 6b123ad..de86103 100644 --- a/Sources/Insomnia/Views/MenuBarView.swift +++ b/Sources/Insomnia/Views/MenuBarView.swift @@ -171,15 +171,23 @@ struct MenuBarView: View { @ViewBuilder private var updateSection: some View { if updateChecker.isUpdateAvailable, let version = updateChecker.latestVersion { - // An update is available — show download button + // An update is available — show download or release link if updateChecker.isDownloading { Text("Downloading v\(version)...") - } else { + } else if updateChecker.downloadURL != nil { + // DMG available — offer direct download Button("Download v\(version)") { Task { await updateChecker.downloadAndInstall() } } + } else { + // Update exists but no DMG asset — show version info only + Text("v\(version) available on GitHub") } } + // Show error from the last check or download, if any + if let error = updateChecker.lastError { + Text(error) + } if updateChecker.isChecking { // A check is in progress Text("Checking for updates...") diff --git a/Sources/InsomniaCore/Power/PowerAssertionManager.swift b/Sources/InsomniaCore/Power/PowerAssertionManager.swift index 743375b..a3066fd 100644 --- a/Sources/InsomniaCore/Power/PowerAssertionManager.swift +++ b/Sources/InsomniaCore/Power/PowerAssertionManager.swift @@ -158,7 +158,7 @@ public final class PowerAssertionManager { /// /// If already caffeinated, the existing assertion is released first. /// - /// - Parameter type: The type of sleep to prevent. Defaults to preventing system sleep. + /// - Parameter type: The type of sleep to prevent. Defaults to preventing display sleep. /// - Throws: If the IOKit assertion cannot be created. public func caffeinate(type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { // Release any existing assertion before creating a new one @@ -181,7 +181,7 @@ public final class PowerAssertionManager { /// /// - Parameters: /// - duration: The duration in seconds to keep the assertion active. - /// - type: The type of sleep to prevent. Defaults to preventing system sleep. + /// - type: The type of sleep to prevent. Defaults to preventing display sleep. /// - Throws: If the IOKit assertion cannot be created. public func caffeinate(for duration: TimeInterval, type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { // Calculate the end date from the duration @@ -197,7 +197,7 @@ public final class PowerAssertionManager { /// /// - Parameters: /// - date: The date at which to automatically decaffeinate. - /// - type: The type of sleep to prevent. Defaults to preventing system sleep. + /// - type: The type of sleep to prevent. Defaults to preventing display sleep. /// - Throws: If the IOKit assertion cannot be created. public func caffeinate(until date: Date, type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { // Release any existing assertion before creating a new one @@ -220,7 +220,7 @@ public final class PowerAssertionManager { /// If currently decaffeinated, starts indefinite caffeination. /// If currently caffeinated (any mode), decaffeinates. /// - /// - Parameter type: The assertion type to use when toggling on. Defaults to system sleep. + /// - Parameter type: The assertion type to use when toggling on. Defaults to display sleep. /// - Throws: If creating or releasing the assertion fails. public func toggle(type: PowerAssertionType = .preventUserIdleDisplaySleep) throws { if state.isActive { @@ -253,7 +253,7 @@ public final class PowerAssertionManager { /// - Parameters: /// - bundleIdentifier: The bundle ID of the watched application. /// - appName: The display name of the watched application. - /// - type: The assertion type. Defaults to preventing system sleep. + /// - type: The assertion type. Defaults to preventing display sleep. /// - Throws: If the IOKit assertion cannot be created. public func caffeinateWhileRunning( bundleIdentifier: String,