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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,31 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Update checks use the Sparkle appcast instead of opening the GitHub releases
page. Automatic checks default on, run at launch and once a day, and never
download or install until you ask.
- Setup and Integrations list each provider with install or remove. Follow-up
instructions and errors stay under the name. Setup finishes as Finish Setup
when an observer is installed, or Finish without Connecting otherwise.
- Activity Center's Active and Attention metrics filter the session list. Its
status filters now keep completed and failed sessions separate.
- Tool lifecycle events with a shared call identifier render as one timeline
entry, even when another important event arrives between them.
- Notch controls show hover and press. Waiting-prompt shortcuts activate after
a click and remain active only while the pointer is over the prompt.
- Primary actions pick black or white text from the system accent so light
yellow, green, and orange fills stay readable.

### Fixed

- A repeated app launch now opens Activity Center in the existing process and
exits before competing for the local event socket. Two overlapping launches
no longer both quit: only a strictly older process is treated as the owner,
and an exclusive lock breaks remaining ties.
- Activity Center's Active and Attention counts follow the current provider,
project, date, and search filters, so clicking a chip matches the number it
shows.
- Activity Center project headings no longer expose the decorative folder as
an unrelated VoiceOver action, and long session titles show in hover help.
- Swift 6 builds no longer warn about notification logging or the hook relay
test's trailing closure.
- Retry after a Sparkle startup failure starts the updater again instead of
doing nothing until relaunch.
- An ineligible update (newer macOS required, and similar Sparkle reasons) no
Expand Down
50 changes: 50 additions & 0 deletions Sources/AgentsNotch/App/AgentsNotchApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,38 @@ import SwiftUI
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let runtime = AppRuntime()
private let instanceCoordinator = AppInstanceCoordinator(
ownershipLock: FileInstanceOwnershipLock()
)
private var panelController: NotchPanelController?
private var activityCenterWindowController: ActivityCenterWindowController?
private var onboardingWindowController: OnboardingWindowController?
private var settingsWindowController: SettingsWindowController?
private var globalShortcutController: GlobalActivityShortcutController?
private var recoveryStatusItem: SurfaceRecoveryStatusItem?
private var handsOffLaunch = false
private var monitorsActivity = false
private var canPresentWindows = false
private var pendingActivationRequest = false

func applicationWillFinishLaunching(_ notification: Notification) {
// Listen before the handoff decision so a slightly later peer can still
// wake this process. The dying process stops observing immediately.
instanceCoordinator.startReceiving { [weak self] in
self?.handleExistingInstanceActivation()
}
handsOffLaunch = instanceCoordinator.handOffIfNeeded()
if handsOffLaunch {
instanceCoordinator.stopReceiving()
}
}

func applicationDidFinishLaunching(_ notification: Notification) {
guard !handsOffLaunch else {
NSApp.terminate(nil)
return
}

var defaults: [String: Any] = [
"animationsEnabled": true,
"displayPreference": DisplayPreference.primary.rawValue,
Expand All @@ -37,6 +61,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
ProcessInfo.processInfo.disableAutomaticTermination(
"Agent Notch monitors local agent activity"
)
monitorsActivity = true
let panel = NotchPanelController(runtime: runtime)
panelController = panel
runtime.panelController = panel
Expand Down Expand Up @@ -68,13 +93,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if !UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") {
showOnboarding()
}
canPresentWindows = true
if pendingActivationRequest {
pendingActivationRequest = false
showActivityCenter()
}
}

func applicationWillTerminate(_ notification: Notification) {
instanceCoordinator.stopReceiving()
guard monitorsActivity else { return }
runtime.stop()
ProcessInfo.processInfo.enableAutomaticTermination(
"Agent Notch monitors local agent activity"
)
monitorsActivity = false
}

func applicationShouldHandleReopen(
_ sender: NSApplication,
hasVisibleWindows flag: Bool
) -> Bool {
showActivityCenter()
return true
}

private func handleExistingInstanceActivation() {
guard !handsOffLaunch else { return }
guard canPresentWindows else {
pendingActivationRequest = true
return
}
showActivityCenter()
}

private func showActivityCenter() {
Expand Down
178 changes: 178 additions & 0 deletions Sources/AgentsNotch/App/AppInstanceCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import AppKit
import Darwin
import Foundation

struct RunningAgentNotchInstance {
let processIdentifier: pid_t
let launchDate: Date
let activate: () -> Bool
}

/// Exclusive lock so two overlapping launches cannot both become the owner.
/// The fcntl lock is released when the process exits, including crashes.
final class FileInstanceOwnershipLock {
private let fd: Int32?
private var holdsLock = false

static var defaultFileURL: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("AgentNotch", isDirectory: true)
.appendingPathComponent("instance.lock")
}

init(fileURL: URL = FileInstanceOwnershipLock.defaultFileURL) {
let directory = fileURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let fd = Darwin.open(fileURL.path, O_CREAT | O_RDWR, mode_t(0o600))
self.fd = fd >= 0 ? fd : nil
}

deinit {
if holdsLock, let fd {
var lock = flock()
lock.l_type = Int16(F_UNLCK)
_ = Darwin.fcntl(fd, F_SETLK, &lock)
}
if let fd {
Darwin.close(fd)
}
}

/// Returns false when another process already owns the instance lock.
/// A missing lock file does not block launch; launch-date ordering still applies.
/// `fcntl` locks are per-process, so two objects in the same process do not contend.
func tryAcquire() -> Bool {
guard !holdsLock else { return true }
guard let fd else { return true }
var lock = flock()
lock.l_type = Int16(F_WRLCK)
lock.l_whence = Int16(SEEK_SET)
if Darwin.fcntl(fd, F_SETLK, &lock) == 0 {
holdsLock = true
return true
}
let error = errno
return error != EAGAIN && error != EACCES
}
}

/// Hands a repeated launch to a strictly older running Agent Notch process.
@MainActor
final class AppInstanceCoordinator: NSObject {
static let activationNotification = Notification.Name(
"com.afonsoferreira.AgentNotch.activateExistingInstance"
)

private let currentProcessIdentifier: pid_t
private let runningInstances: () -> [RunningAgentNotchInstance]
private let postActivationRequest: (pid_t?) -> Void
private let tryAcquireOwnership: () -> Bool
private let distributedCenter: DistributedNotificationCenter
private var onActivationRequest: (() -> Void)?
private var isObserving = false

init(
currentProcessIdentifier: pid_t = ProcessInfo.processInfo.processIdentifier,
bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "com.afonsoferreira.AgentNotch",
distributedCenter: DistributedNotificationCenter = .default(),
runningInstances: (() -> [RunningAgentNotchInstance])? = nil,
postActivationRequest: ((pid_t?) -> Void)? = nil,
tryAcquireOwnership: (() -> Bool)? = nil,
ownershipLock: FileInstanceOwnershipLock? = nil
) {
self.currentProcessIdentifier = currentProcessIdentifier
self.distributedCenter = distributedCenter
self.runningInstances = runningInstances ?? {
NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).map { application in
RunningAgentNotchInstance(
processIdentifier: application.processIdentifier,
launchDate: application.launchDate ?? .distantFuture,
activate: {
application.activate(options: [.activateAllWindows])
}
)
}
}
self.postActivationRequest = postActivationRequest ?? { processIdentifier in
distributedCenter.postNotificationName(
Self.activationNotification,
object: processIdentifier.map(String.init),
userInfo: nil,
deliverImmediately: true
)
}
self.tryAcquireOwnership = tryAcquireOwnership
?? { ownershipLock?.tryAcquire() ?? true }
super.init()
}

/// Yields only to a process that launched earlier than this one. A newer
/// peer is not "the existing instance", so two overlapping launches cannot
/// both hand off and quit. The ownership lock is the tie-breaker when
/// launch dates are missing or a peer already claimed ownership.
func handOffIfNeeded() -> Bool {
let instances = runningInstances()
let currentLaunchDate = instances
.first { $0.processIdentifier == currentProcessIdentifier }?
.launchDate
let peers = instances.filter { $0.processIdentifier != currentProcessIdentifier }

if let currentLaunchDate,
let existing = peers
.filter({ $0.launchDate < currentLaunchDate })
.min(by: { $0.launchDate < $1.launchDate })
{
return handOff(to: existing)
}

if tryAcquireOwnership() {
return false
}

if let peer = peers.min(by: { $0.launchDate < $1.launchDate }) {
return handOff(to: peer)
}

postActivationRequest(nil)
return true
}

private func handOff(to instance: RunningAgentNotchInstance) -> Bool {
postActivationRequest(instance.processIdentifier)
_ = instance.activate()
return true
}

func startReceiving(onActivationRequest: @escaping () -> Void) {
self.onActivationRequest = onActivationRequest
guard !isObserving else { return }
isObserving = true
distributedCenter.addObserver(
self,
selector: #selector(handleActivationRequest),
name: Self.activationNotification,
object: nil,
suspensionBehavior: .deliverImmediately
)
}

func stopReceiving() {
guard isObserving else { return }
distributedCenter.removeObserver(
self,
name: Self.activationNotification,
object: nil
)
isObserving = false
onActivationRequest = nil
}

@objc private func handleActivationRequest(_ notification: Notification) {
if let object = notification.object as? String,
object != String(currentProcessIdentifier)
{
return
}
onActivationRequest?()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ enum ProviderIntegrationStatus: Equatable {
case .awaitingFirstEvent, .connected: false
}
}

var isInstalled: Bool {
switch self {
case .awaitingFirstEvent, .connected: true
case .notInstalled, .unavailable: false
}
}

var isConnected: Bool { self == .connected }
}

@Observable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ final class AgentNotificationService: NSObject, UNUserNotificationCenterDelegate
var onOpenSession: ((String) -> Void)?

private let center: UNUserNotificationCenter?
private static let logger = Logger(
nonisolated private static let logger = Logger(
subsystem: "com.afonsoferreira.AgentNotch",
category: "notifications"
)
Expand Down
23 changes: 20 additions & 3 deletions Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct ActivityCenterHeader: View {
let sessionCount: Int
let activeCount: Int
let attentionCount: Int
let statusFilter: Binding<ActivityStatusFilter>
let groupingMode: Binding<ActivityGroupingMode>
let canClearHistory: Bool
let requestClearHistory: () -> Void
Expand All @@ -20,16 +21,28 @@ struct ActivityCenterHeader: View {
.foregroundStyle(NotchWindowPalette.secondaryText)
}
Spacer()
ActivityMetric(title: "Active", value: activeCount, color: .blue)
ActivityMetric(title: "Attention", value: attentionCount, color: .orange)
ActivityMetric(
title: "Active",
value: activeCount,
color: .blue,
isSelected: statusFilter.wrappedValue == .active,
action: { toggleStatusFilter(.active) }
)
ActivityMetric(
title: "Attention",
value: attentionCount,
color: .orange,
isSelected: statusFilter.wrappedValue == .attention,
action: { toggleStatusFilter(.attention) }
)
Menu {
Picker("Session Grouping", selection: groupingMode) {
ForEach(ActivityGroupingMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
Divider()
Button("Clear Completed History", role: .destructive, action: requestClearHistory)
Button("Clear Finished History", role: .destructive, action: requestClearHistory)
.disabled(!canClearHistory)
Divider()
Button("Quit Agent Notch") {
Expand All @@ -47,6 +60,10 @@ struct ActivityCenterHeader: View {
.padding(.vertical, 14)
.background(NotchWindowPalette.background)
}

private func toggleStatusFilter(_ filter: ActivityStatusFilter) {
statusFilter.wrappedValue = statusFilter.wrappedValue == filter ? .all : filter
}
}

struct ActivityCenterEmptyDetail: View {
Expand Down
Loading
Loading