Skip to content

Commit 85cdfd5

Browse files
GordonBeemingclaudegitbutler-client
authored
fix: Always prevent display sleep, remove dock icon, add auto-updates (#14)
* 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) <noreply@anthropic.com> Co-authored-by: GitButler <gitbutler@gitbutler.com> * 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) <noreply@anthropic.com> Co-authored-by: GitButler <gitbutler@gitbutler.com> * 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) <noreply@anthropic.com> Co-authored-by: GitButler <gitbutler@gitbutler.com> * 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) <noreply@anthropic.com> Co-authored-by: GitButler <gitbutler@gitbutler.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: GitButler <gitbutler@gitbutler.com>
1 parent a64268f commit 85cdfd5

20 files changed

Lines changed: 622 additions & 258 deletions

CLAUDE.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ Three targets in `Package.swift`:
2020
## Build & Test
2121

2222
```bash
23-
swift build # debug build
24-
swift test # 118+ tests
25-
swift run Insomnia # run GUI locally
26-
swift run InsomniaCLI status # run CLI
23+
swift build # debug build
24+
swift build -Xswiftc -strict-concurrency=complete # build with strict concurrency (matches CI)
25+
swift test # 133+ tests
26+
swift run Insomnia # run GUI locally
27+
swift run InsomniaCLI status # run CLI
2728
```
2829

30+
**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.
31+
2932
## Code Comments
3033

3134
85%+ comment coverage required. Every file needs:
@@ -79,8 +82,6 @@ This lets dev and prod run side-by-side.
7982
## Window Focus Pattern
8083

8184
LSUIElement apps need special handling to show windows:
82-
1. `NSApp.setActivationPolicy(.regular)` before opening
83-
2. `openWindow(id:)` to open
84-
3. Reapply app icon via `AppDelegate.reapplyAppIcon()`
85-
4. `NSApp.activate(ignoringOtherApps: true)` after short delay
86-
5. Return to `.accessory` in `onDisappear` (unless dock icon enabled)
85+
1. `openWindow(id:)` to open
86+
2. `NSApp.activate(ignoringOtherApps: true)` after short delay
87+
The app always stays in `.accessory` mode (no dock icon).

Sources/Insomnia/AppDelegate.swift

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// AppDelegate.swift — Insomnia GUI
22
//
33
// NSApplicationDelegate managing the application lifecycle. Owns the shared
4-
// CaffeinationScheduler, IPCServer, and DockIconController. Starts the IPC
5-
// server on launch and cleans up all resources on termination.
4+
// CaffeinationScheduler and IPCServer. Starts the IPC server on launch
5+
// and cleans up all resources on termination.
66

77
import AppKit
88
import InsomniaCore
@@ -13,7 +13,7 @@ import InsomniaCore
1313
/// - Creates and owns the shared ``CaffeinationScheduler`` used by both
1414
/// the GUI and the IPC server
1515
/// - Starts the ``IPCServer`` on launch so the CLI can communicate
16-
/// - Owns the ``DockIconController`` for dock tile updates
16+
/// - Starts the ``UpdateChecker`` for periodic GitHub release checks
1717
/// - Releases all power assertions and stops the IPC server on termination
1818
final class AppDelegate: NSObject, NSApplicationDelegate {
1919
// MARK: - Shared State
@@ -25,18 +25,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
2525
/// The user configuration shared across the application.
2626
let configuration = InsomniaConfiguration()
2727

28+
/// The update checker that periodically queries GitHub for new releases.
29+
let updateChecker = UpdateChecker()
30+
2831
// MARK: - Owned Controllers
2932

3033
/// The IPC server that receives commands from the CLI.
3134
/// Initialized lazily when the app finishes launching.
3235
private var ipcServer: IPCServer?
3336

34-
/// The dock icon controller for updating the dock tile image.
35-
let dockIconController = DockIconController()
36-
37-
/// The loaded app icon, cached so it can be reapplied when switching activation policies.
38-
var cachedAppIcon: NSImage?
39-
4037
// MARK: - NSApplicationDelegate
4138

4239
/// Called when the application finishes launching.
@@ -58,8 +55,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5855
// This replaces the generic "exec" terminal icon when running via `swift run`
5956
loadAppIcon()
6057

61-
// Set the app's menu bar to accessory mode (no dock icon by default)
58+
// Ensure the app runs as a menu bar-only app (no dock icon)
6259
NSApp.setActivationPolicy(.accessory)
60+
61+
// Start periodic update checks (hourly, with an initial check on launch)
62+
updateChecker.startPeriodicChecks()
6363
}
6464

6565
/// Called when the application is about to terminate.
@@ -74,25 +74,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
7474
print("Failed to cancel caffeination on quit: \(error.localizedDescription)")
7575
}
7676

77+
// Stop periodic update checks
78+
updateChecker.stopPeriodicChecks()
79+
7780
// Stop the IPC server and remove the socket file
7881
ipcServer?.stop()
7982
ipcServer = nil
8083
}
8184

8285
// MARK: - Private Helpers
8386

84-
/// Loads AppIcon.icns and caches it for reuse when switching activation policies.
87+
/// Loads AppIcon.icns and sets it as the application icon.
8588
private func loadAppIcon() {
8689
// Try loading from the app bundle first (release builds)
8790
if let bundleIcon = Bundle.main.image(forResource: "AppIcon") {
88-
cachedAppIcon = bundleIcon
8991
NSApp.applicationIconImage = bundleIcon
9092
return
9193
}
9294
// Try from current working directory (most reliable for `swift run`)
9395
let cwdPath = FileManager.default.currentDirectoryPath + "/Resources/AppIcon.icns"
9496
if let icon = NSImage(contentsOfFile: cwdPath) {
95-
cachedAppIcon = icon
9697
NSApp.applicationIconImage = icon
9798
return
9899
}
@@ -102,19 +103,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
102103
for _ in 0..<6 {
103104
let iconPath = searchDir.appendingPathComponent("Resources/AppIcon.icns").path
104105
if let icon = NSImage(contentsOfFile: iconPath) {
105-
cachedAppIcon = icon
106106
NSApp.applicationIconImage = icon
107107
return
108108
}
109109
searchDir = searchDir.deletingLastPathComponent()
110110
}
111111
}
112-
113-
/// Reapplies the cached app icon. Call after switching activation policy
114-
/// since macOS resets the icon when toggling between .regular and .accessory.
115-
func reapplyAppIcon() {
116-
if let icon = cachedAppIcon {
117-
NSApp.applicationIconImage = icon
118-
}
119-
}
120112
}

Sources/Insomnia/DockIconController.swift

Lines changed: 0 additions & 78 deletions
This file was deleted.

Sources/Insomnia/InsomniaApp.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ struct InsomniaApp: App {
3232
MenuBarExtra {
3333
// Render the menu content if the view model is ready
3434
if let viewModel {
35-
MenuBarView(viewModel: viewModel)
35+
MenuBarView(viewModel: viewModel, updateChecker: appDelegate.updateChecker)
3636
}
3737
} label: {
3838
// Menu bar icon changes based on caffeination state
@@ -82,12 +82,17 @@ struct InsomniaApp: App {
8282
// MARK: - Menu Bar Label
8383

8484
/// The label displayed in the menu bar — an SF Symbol that changes
85-
/// based on the current caffeination state.
85+
/// based on the current caffeination state. Shows a down arrow indicator
86+
/// when an update is available.
8687
@ViewBuilder
8788
private var menuBarLabel: some View {
8889
if let viewModel {
8990
// Show coffee cup when awake, moon when sleeping
9091
Image(systemName: viewModel.menuBarImage)
92+
// Show a down arrow indicator when an update is available
93+
if appDelegate.updateChecker.isUpdateAvailable {
94+
Text("\u{2B07}")
95+
}
9196
} else {
9297
// Fallback icon before the view model initializes
9398
Image(systemName: "moon.zzz")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// GitHubRelease.swift — Insomnia GUI
2+
//
3+
// Codable model for the GitHub Releases API response. Only decodes the
4+
// fields needed for update checking: the release tag, page URL, and
5+
// downloadable asset information.
6+
7+
import Foundation
8+
9+
/// Represents a GitHub release from the Releases API.
10+
///
11+
/// Used to decode the response from
12+
/// `https://api.github.com/repos/gordonbeeming/insomnia/releases/latest`.
13+
/// Only the fields needed for update checking are included.
14+
struct GitHubRelease: Codable {
15+
/// The git tag for this release (e.g., "v0.6").
16+
let tagName: String
17+
18+
/// The URL of the release page on GitHub.
19+
let htmlUrl: String
20+
21+
/// The downloadable assets attached to this release.
22+
let assets: [Asset]
23+
24+
/// A single downloadable file attached to a GitHub release.
25+
struct Asset: Codable {
26+
/// The filename of the asset (e.g., "Insomnia-0.6.dmg").
27+
let name: String
28+
29+
/// The direct download URL for the asset.
30+
let browserDownloadUrl: String
31+
}
32+
33+
/// Finds the DMG asset for the macOS GUI application.
34+
///
35+
/// Searches the assets list for a file ending in `.dmg`.
36+
/// - Returns: The DMG asset if found, or `nil` if no DMG is attached.
37+
var dmgAsset: Asset? {
38+
// Look for the DMG file in the release assets
39+
return assets.first { $0.name.hasSuffix(".dmg") }
40+
}
41+
}

0 commit comments

Comments
 (0)