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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Nightcap/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct AppFeature {
@Shared(.fileStorage(.documentsDirectory.appending(component: "watched-apps.json")))
var watchedApps: [WatchedApp] = [.ghostty]
var runningWatchedIDs: Set<String> = []
var runningAppCandidates: [WatchedApp] = []
var launchAtLoginStatus: LaunchAtLoginStatus = .unknown
var assertionHeld = false
}
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions Nightcap/Services/AppLifecycleClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import DependenciesMacros
@DependencyClient
struct AppLifecycleClient: Sendable {
var runningBundleIDs: @Sendable () -> Set<String> = { [] }
var runningApps: @Sendable () -> [WatchedApp] = { [] }
var events: @Sendable () -> AsyncStream<Event> = { .finished }

enum Event: Sendable, Equatable {
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions Nightcap/SharedUI/AddRunningAppMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import SwiftUI

struct AddRunningAppMenu: View {
let candidates: [WatchedApp]
let watchedBundleIDs: Set<String>
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)
}
}
37 changes: 37 additions & 0 deletions Nightcap/SharedUI/MenuActionsSection.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
75 changes: 75 additions & 0 deletions Nightcap/SharedUI/MenuAppPicker.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading