Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -79,8 +82,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).
36 changes: 14 additions & 22 deletions Sources/Insomnia/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +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
/// - Owns the ``DockIconController`` for dock tile updates
/// - 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
Expand All @@ -25,18 +25,15 @@ 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.
/// 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.
Expand All @@ -58,8 +55,11 @@ 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)

// Start periodic update checks (hourly, with an initial check on launch)
updateChecker.startPeriodicChecks()
}

/// Called when the application is about to terminate.
Expand All @@ -74,25 +74,26 @@ 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
}

// 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
}
Expand All @@ -102,19 +103,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
}
}
}
78 changes: 0 additions & 78 deletions Sources/Insomnia/DockIconController.swift

This file was deleted.

9 changes: 7 additions & 2 deletions Sources/Insomnia/InsomniaApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions Sources/Insomnia/Update/GitHubRelease.swift
Original file line number Diff line number Diff line change
@@ -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") }
}
}
Loading
Loading