diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fa607..d355db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Add Running App menu for watching apps that are already open, with watched + apps shown as disabled checkmarked rows. + ## [1.0] - 2026-05-14 ### Added diff --git a/Nightcap/AppFeature.swift b/Nightcap/AppFeature.swift index 576f0c7..c6b0e49 100644 --- a/Nightcap/AppFeature.swift +++ b/Nightcap/AppFeature.swift @@ -10,6 +10,7 @@ struct AppFeature { @Shared(.fileStorage(.documentsDirectory.appending(component: "watched-apps.json"))) var watchedApps: [WatchedApp] = [.ghostty] var runningWatchedIDs: Set = [] + var runningAppCandidates: [WatchedApp] = [] var launchAtLoginStatus: LaunchAtLoginStatus = .unknown var assertionHeld = false } @@ -18,6 +19,7 @@ struct AppFeature { case onAppear case lifecycleEvent(AppLifecycleClient.Event) case reconcile + case runningAppCandidatesRefreshRequested case addAppRequested(WatchedApp) case removeAppRequested(WatchedApp.ID) case observationToggled(WatchedApp.ID, Bool) @@ -39,6 +41,7 @@ struct AppFeature { case .onAppear: state.launchAtLoginStatus = launchAtLogin.status() reconcileRunning(&state) + refreshRunningAppCandidates(&state) return .run { send in for await event in lifecycle.events() { await send(.lifecycleEvent(event)) @@ -47,12 +50,14 @@ struct AppFeature { .cancellable(id: CancelID.lifecycle, cancelInFlight: true) case let .lifecycleEvent(.launched(id)): + refreshRunningAppCandidates(&state) guard state.watchedApps.contains(where: { $0.bundleID == id && $0.isObserved }) else { return .none } state.runningWatchedIDs.insert(id) syncAssertion(&state) return .none case let .lifecycleEvent(.terminated(id)): + refreshRunningAppCandidates(&state) guard state.runningWatchedIDs.contains(id) else { return .none } if !lifecycle.runningBundleIDs().contains(id) { state.runningWatchedIDs.remove(id) @@ -62,6 +67,11 @@ struct AppFeature { case .lifecycleEvent(.wake), .reconcile: reconcileRunning(&state) + refreshRunningAppCandidates(&state) + return .none + + case .runningAppCandidatesRefreshRequested: + refreshRunningAppCandidates(&state) return .none case let .addAppRequested(app): @@ -140,6 +150,10 @@ struct AppFeature { Set(state.watchedApps.filter(\.isObserved).map(\.bundleID)) } + private func refreshRunningAppCandidates(_ state: inout State) { + state.runningAppCandidates = lifecycle.runningApps() + } + private func syncAssertion(_ state: inout State) { if state.runningWatchedIDs.isEmpty { assertion.release() diff --git a/Nightcap/Services/AppLifecycleClient.swift b/Nightcap/Services/AppLifecycleClient.swift index a00ca57..4daac35 100644 --- a/Nightcap/Services/AppLifecycleClient.swift +++ b/Nightcap/Services/AppLifecycleClient.swift @@ -5,6 +5,7 @@ import DependenciesMacros @DependencyClient struct AppLifecycleClient: Sendable { var runningBundleIDs: @Sendable () -> Set = { [] } + var runningApps: @Sendable () -> [WatchedApp] = { [] } var events: @Sendable () -> AsyncStream = { .finished } enum Event: Sendable, Equatable { @@ -19,6 +20,26 @@ extension AppLifecycleClient: DependencyKey { runningBundleIDs: { Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier)) }, + runningApps: { + let ownBundleID = Bundle.main.bundleIdentifier + let apps = NSWorkspace.shared.runningApplications.compactMap { app -> WatchedApp? in + guard + app.activationPolicy == .regular, + let bundleID = app.bundleIdentifier, + bundleID != ownBundleID, + let displayName = app.localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), + !displayName.isEmpty + else { return nil } + + return WatchedApp(bundleID: bundleID, displayName: displayName) + } + + return Dictionary(grouping: apps, by: \.bundleID) + .compactMap { $0.value.first } + .sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + }, events: { AsyncStream { continuation in let center = NSWorkspace.shared.notificationCenter diff --git a/Nightcap/SharedUI/AddRunningAppMenu.swift b/Nightcap/SharedUI/AddRunningAppMenu.swift new file mode 100644 index 0000000..72e12b6 --- /dev/null +++ b/Nightcap/SharedUI/AddRunningAppMenu.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct AddRunningAppMenu: View { + let candidates: [WatchedApp] + let watchedBundleIDs: Set + let onAdd: (WatchedApp) -> Void + let onRefresh: () -> Void + + var body: some View { + Menu("Add Running App") { + candidateItems + + Divider() + + Button("Refresh Running Apps") { + onRefresh() + } + } + } + + @ViewBuilder + private var candidateItems: some View { + if candidates.isEmpty { + Text("No running apps found") + .foregroundStyle(.secondary) + } else { + ForEach(candidates) { app in + RunningAppCandidateButton( + app: app, + isAlreadyWatched: watchedBundleIDs.contains(app.bundleID), + onAdd: onAdd + ) + } + } + } +} + +private struct RunningAppCandidateButton: View { + let app: WatchedApp + let isAlreadyWatched: Bool + let onAdd: (WatchedApp) -> Void + + var body: some View { + Button { + onAdd(app) + } label: { + Label( + app.displayName, + systemImage: isAlreadyWatched ? "checkmark" : "plus" + ) + } + .disabled(isAlreadyWatched) + } +} diff --git a/Nightcap/SharedUI/MenuActionsSection.swift b/Nightcap/SharedUI/MenuActionsSection.swift new file mode 100644 index 0000000..8ff8d46 --- /dev/null +++ b/Nightcap/SharedUI/MenuActionsSection.swift @@ -0,0 +1,37 @@ +import AppKit +import ServiceManagement +import SwiftUI + +struct MenuActionsSection: View { + let launchAtLoginStatus: LaunchAtLoginStatus + let onLaunchAtLoginToggle: (Bool) -> Void + let onQuit: () -> Void + + var body: some View { + Toggle( + "Launch at Login", + isOn: Binding( + get: { launchAtLoginStatus.isOn }, + set: onLaunchAtLoginToggle + ) + ) + + if case .requiresApproval = launchAtLoginStatus { + Button("Approve in System Settings…") { + SMAppService.openSystemSettingsLoginItems() + } + } + + Button("About Nightcap") { + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel(nil) + } + + Divider() + + Button("Quit Nightcap") { + onQuit() + } + .keyboardShortcut("q") + } +} diff --git a/Nightcap/SharedUI/MenuAppPicker.swift b/Nightcap/SharedUI/MenuAppPicker.swift new file mode 100644 index 0000000..3cdc816 --- /dev/null +++ b/Nightcap/SharedUI/MenuAppPicker.swift @@ -0,0 +1,75 @@ +import AppKit +import UniformTypeIdentifiers + +enum MenuAppPicker { + static func present(existingApps: [WatchedApp], onSelect: (WatchedApp) -> Void) { + NSApp.activate() + + let panel = makePanel() + let response = panel.runModal() + NSApp.setActivationPolicy(.accessory) + + guard response == .OK, let url = panel.url else { return } + guard let selectedApp = makeWatchedApp(from: url) else { + presentAlert( + title: "Couldn't read app info", + message: "That file isn't a recognizable app bundle. Try picking another." + ) + return + } + + if let existingApp = existingApp(matching: selectedApp, in: existingApps) { + presentAlert( + title: "Already in your list", + message: alreadyWatchedMessage(for: existingApp) + ) + return + } + + onSelect(selectedApp) + } + + private static func makePanel() -> NSOpenPanel { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.prompt = "Watch" + panel.message = "Pick an app to keep your Mac awake while it's running." + return panel + } + + private static func makeWatchedApp(from url: URL) -> WatchedApp? { + guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { + return nil + } + + let displayName = FileManager.default.displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + return WatchedApp(bundleID: bundleID, displayName: displayName) + } + + private static func existingApp( + matching selectedApp: WatchedApp, + in existingApps: [WatchedApp] + ) -> WatchedApp? { + existingApps.first { $0.bundleID == selectedApp.bundleID } + } + + private static func alreadyWatchedMessage(for app: WatchedApp) -> String { + app.isObserved + ? "\(app.displayName) is already being watched." + : "\(app.displayName) is already in your list. Choose Resume Watching from its menu to watch it again." + } + + private static func presentAlert(title: String, message: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/Nightcap/SharedUI/MenuContentView.swift b/Nightcap/SharedUI/MenuContentView.swift index c66bfd0..2b825cf 100644 --- a/Nightcap/SharedUI/MenuContentView.swift +++ b/Nightcap/SharedUI/MenuContentView.swift @@ -1,148 +1,64 @@ -import AppKit import ComposableArchitecture -import ServiceManagement +import Foundation import SwiftUI -import UniformTypeIdentifiers struct MenuContentView: View { @Bindable var store: StoreOf var body: some View { - statusLine + MenuStatusSection( + assertionHeld: store.assertionHeld, + activeAppCount: store.runningWatchedIDs.count + ) Divider() - if store.watchedApps.isEmpty { - Text("No apps added") - .foregroundStyle(.secondary) - } else { - ForEach(store.watchedApps) { app in - Menu { - Button(app.isObserved ? "Pause Watching" : "Resume Watching") { - store.send(.observationToggled(app.id, !app.isObserved)) - } - - Button("Remove from List", role: .destructive) { - store.send(.removeAppRequested(app.id)) - } - } label: { - HStack { - Image(systemName: statusIcon(for: app)) - .foregroundStyle(statusColor(for: app)) - Text(app.displayName) - if !app.isObserved { - Text("Paused") - .foregroundStyle(.secondary) - } - } - } + WatchedAppsMenuSection( + watchedApps: store.watchedApps, + runningWatchedIDs: store.runningWatchedIDs, + onObservationToggle: { id, isObserved in + store.send(.observationToggled(id, isObserved)) + }, + onRemove: { id in + store.send(.removeAppRequested(id)) } - } - - Button("Add App…") { presentAppPicker() } - - Divider() - - Toggle( - "Launch at Login", - isOn: Binding( - get: { store.launchAtLoginStatus.isOn }, - set: { store.send(.launchAtLoginToggled($0)) } - ) ) - if case .requiresApproval = store.launchAtLoginStatus { - Button("Approve in System Settings…") { - SMAppService.openSystemSettingsLoginItems() + AddRunningAppMenu( + candidates: store.runningAppCandidates, + watchedBundleIDs: watchedBundleIDs, + onAdd: { app in + store.send(.addAppRequested(app)) + }, + onRefresh: { + store.send(.runningAppCandidatesRefreshRequested) } - } + ) - Button("About Nightcap") { - NSApp.activate(ignoringOtherApps: true) - NSApp.orderFrontStandardAboutPanel(nil) - } + Button("Add App…") { presentAppPicker() } Divider() - Button("Quit Nightcap") { store.send(.quitTapped) } - .keyboardShortcut("q") - } - - @ViewBuilder - private var statusLine: some View { - if store.assertionHeld { - Label("Keeping Mac Awake", systemImage: "cup.and.saucer.fill") - Text(activeAppsLabel) - .foregroundStyle(.secondary) - } else { - Label("Idle", systemImage: "moon.zzz") - Text("Sleep allowed") - .foregroundStyle(.secondary) - } + MenuActionsSection( + launchAtLoginStatus: store.launchAtLoginStatus, + onLaunchAtLoginToggle: { isEnabled in + store.send(.launchAtLoginToggled(isEnabled)) + }, + onQuit: { + store.send(.quitTapped) + } + ) } - private var activeAppsLabel: String { - let count = store.runningWatchedIDs.count - return count == 1 ? "1 app active" : "\(count) apps active" + private var watchedBundleIDs: Set { + Set(store.watchedApps.map(\.bundleID)) } private func presentAppPicker() { DispatchQueue.main.async { - NSApp.activate() - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.allowedContentTypes = [.application] - panel.directoryURL = URL(fileURLWithPath: "/Applications") - panel.prompt = "Watch" - panel.message = "Pick an app to keep your Mac awake while it's running." - - let response = panel.runModal() - NSApp.setActivationPolicy(.accessory) - - guard response == .OK, let url = panel.url else { return } - - guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { - presentAlert( - title: "Couldn't read app info", - message: "That file isn't a recognizable app bundle. Try picking another." - ) - return - } - - if let existing = store.watchedApps.first(where: { $0.bundleID == bundleID }) { - presentAlert( - title: "Already in your list", - message: existing.isObserved - ? "\(existing.displayName) is already being watched." - : "\(existing.displayName) is already in your list. Choose Resume Watching from its menu to watch it again." - ) - return + MenuAppPicker.present(existingApps: store.watchedApps) { app in + store.send(.addAppRequested(app)) } - - let displayName = FileManager.default.displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") - store.send(.addAppRequested(WatchedApp(bundleID: bundleID, displayName: displayName))) } } - - private func presentAlert(title: String, message: String) { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.runModal() - } - - private func statusIcon(for app: WatchedApp) -> String { - guard app.isObserved else { return "pause.circle" } - return store.runningWatchedIDs.contains(app.bundleID) ? "circle.fill" : "circle" - } - - private func statusColor(for app: WatchedApp) -> Color { - guard app.isObserved else { return .secondary } - return store.runningWatchedIDs.contains(app.bundleID) ? .green : .secondary - } } diff --git a/Nightcap/SharedUI/MenuStatusSection.swift b/Nightcap/SharedUI/MenuStatusSection.swift new file mode 100644 index 0000000..dfb5d71 --- /dev/null +++ b/Nightcap/SharedUI/MenuStatusSection.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct MenuStatusSection: View { + let assertionHeld: Bool + let activeAppCount: Int + + var body: some View { + if assertionHeld { + Label("Keeping Mac Awake", systemImage: "cup.and.saucer.fill") + Text(activeAppsLabel) + .foregroundStyle(.secondary) + } else { + Label("Idle", systemImage: "moon.zzz") + Text("Sleep allowed") + .foregroundStyle(.secondary) + } + } + + private var activeAppsLabel: String { + activeAppCount == 1 ? "1 app active" : "\(activeAppCount) apps active" + } +} diff --git a/Nightcap/SharedUI/WatchedAppsMenuSection.swift b/Nightcap/SharedUI/WatchedAppsMenuSection.swift new file mode 100644 index 0000000..a475672 --- /dev/null +++ b/Nightcap/SharedUI/WatchedAppsMenuSection.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct WatchedAppsMenuSection: View { + let watchedApps: [WatchedApp] + let runningWatchedIDs: Set + let onObservationToggle: (WatchedApp.ID, Bool) -> Void + let onRemove: (WatchedApp.ID) -> Void + + var body: some View { + if watchedApps.isEmpty { + Text("No apps added") + .foregroundStyle(.secondary) + } else { + ForEach(watchedApps) { app in + WatchedAppMenu( + app: app, + isRunning: runningWatchedIDs.contains(app.bundleID), + onObservationToggle: { isObserved in + onObservationToggle(app.id, isObserved) + }, + onRemove: { + onRemove(app.id) + } + ) + } + } + } +} + +private struct WatchedAppMenu: View { + let app: WatchedApp + let isRunning: Bool + let onObservationToggle: (Bool) -> Void + let onRemove: () -> Void + + var body: some View { + Menu { + Button(app.isObserved ? "Pause Watching" : "Resume Watching") { + onObservationToggle(!app.isObserved) + } + + Button("Remove from List", role: .destructive) { + onRemove() + } + } label: { + WatchedAppMenuLabel( + app: app, + status: WatchedAppMenuStatus(isObserved: app.isObserved, isRunning: isRunning) + ) + } + } +} + +private struct WatchedAppMenuLabel: View { + let app: WatchedApp + let status: WatchedAppMenuStatus + + var body: some View { + HStack { + Image(systemName: status.iconName) + .foregroundStyle(status.color) + Text(app.displayName) + if !app.isObserved { + Text("Paused") + .foregroundStyle(.secondary) + } + } + } +} + +private struct WatchedAppMenuStatus { + let isObserved: Bool + let isRunning: Bool + + var iconName: String { + guard isObserved else { return "pause.circle" } + return isRunning ? "circle.fill" : "circle" + } + + var color: Color { + guard isObserved else { return .secondary } + return isRunning ? .green : .secondary + } +} diff --git a/NightcapTests/NightcapAppTests.swift b/NightcapTests/NightcapAppTests.swift index 4669d9d..a11b5ea 100644 --- a/NightcapTests/NightcapAppTests.swift +++ b/NightcapTests/NightcapAppTests.swift @@ -75,6 +75,52 @@ final class NightcapAppTests: XCTestCase { } } + func test_on_appear_loads_running_app_candidates() async { + let xcode = WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode") + let zoom = WatchedApp(bundleID: "us.zoom.xos", displayName: "zoom.us") + let env = makeEnv(running: [], runningApps: [xcode, zoom]) + let store = makeStore(env: env) + + await store.send(.onAppear) { + $0.launchAtLoginStatus = .disabled + $0.runningAppCandidates = [xcode, zoom] + } + } + + func test_launch_event_for_unwatched_app_refreshes_running_app_candidates() async { + let xcode = WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode") + let env = makeEnv(running: [], runningApps: []) + let store = makeStore(env: env) + + await store.send(.onAppear) { + $0.launchAtLoginStatus = .disabled + } + + env.runningApps.setValue([xcode]) + await store.send(.lifecycleEvent(.launched(bundleID: xcode.bundleID))) { + $0.runningAppCandidates = [xcode] + } + } + + func test_adding_running_app_uses_existing_watch_flow() async { + let xcode = WatchedApp(bundleID: "com.apple.dt.Xcode", displayName: "Xcode") + let env = makeEnv(running: [xcode.bundleID], runningApps: [xcode]) + let store = makeStore(env: env) + + await store.send(.onAppear) { + $0.runningAppCandidates = [xcode] + $0.launchAtLoginStatus = .disabled + } + + await store.send(.addAppRequested(xcode)) { + $0.$watchedApps.withLock { $0.append(xcode) } + $0.runningWatchedIDs = [xcode.bundleID] + $0.assertionHeld = true + } + + XCTAssertEqual(env.acquired.value, ["Nightcap: Xcode"]) + } + func test_duplicate_add_is_a_no_op() async { let env = makeEnv(running: []) let store = makeStore(env: env) @@ -161,6 +207,7 @@ final class NightcapAppTests: XCTestCase { AppFeature() } withDependencies: { $0.appLifecycleClient.runningBundleIDs = { [] } + $0.appLifecycleClient.runningApps = { [] } $0.appLifecycleClient.events = { .finished } $0.launchAtLoginClient.status = { .disabled } $0.launchAtLoginClient.setEnabled = { _ in throw TestError.simulated } @@ -199,13 +246,18 @@ final class NightcapAppTests: XCTestCase { private struct TestEnv { let running: LockIsolated> + let runningApps: LockIsolated<[WatchedApp]> let acquired: LockIsolated<[String]> let released: LockIsolated } - private func makeEnv(running: Set) -> TestEnv { + private func makeEnv( + running: Set, + runningApps: [WatchedApp] = [] + ) -> TestEnv { TestEnv( running: LockIsolated(running), + runningApps: LockIsolated(runningApps), acquired: LockIsolated([]), released: LockIsolated(0) ) @@ -219,6 +271,7 @@ final class NightcapAppTests: XCTestCase { AppFeature() } withDependencies: { $0.appLifecycleClient.runningBundleIDs = { env.running.value } + $0.appLifecycleClient.runningApps = { env.runningApps.value } $0.appLifecycleClient.events = { .finished } $0.launchAtLoginClient.status = { .disabled } $0.powerAssertionClient.acquire = { reason in diff --git a/README.md b/README.md index a0a59f6..598e56b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ menu bar — no Dock icon. ## Usage 1. Click the cup icon in your menu bar -2. Click **Add App…**, pick the app you want to watch (e.g. Ghostty) +2. Choose **Add Running App** for an app that's already open, or click + **Add App…** to pick one from disk (e.g. Ghostty) 3. While that app is running, the icon switches to the filled-cup state and your Mac won't sleep 4. Choose **Pause Watching** to keep an app in your list without holding sleep, @@ -90,9 +91,10 @@ xcodebuild test -project Nightcap.xcodeproj -scheme Nightcap \ -destination 'platform=macOS,arch=arm64' ``` -11 unit tests cover launch/terminate/wake reconciliation, multi-instance +14 unit tests cover launch/terminate/wake reconciliation, multi-instance termination, duplicate-add no-op, launch-at-login error rollback, -pause/resume watching, legacy list migration, and quit releases the assertion. +pause/resume watching, running-app suggestions, legacy list migration, and quit +releases the assertion. ## Privacy