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
51 changes: 37 additions & 14 deletions Sources/main.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import Cocoa
import UserNotifications

// Cancelable termination timer; didReceive cancels it to avoid racing.
var terminationWork: DispatchWorkItem?

class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
// Cancel the termination timer so the app stays alive for click handling.
// cancel() is thread-safe; the variable is written once before app.run()
// and only read here, so no synchronization is needed.
terminationWork?.cancel()

let userInfo = response.notification.request.content.userInfo
// Execute shell command
if let command = userInfo["execute"] as? String {
Expand Down Expand Up @@ -150,13 +158,19 @@ func printUsage() {
--sound <name> Sound name in ~/Library/Sounds or /System/Library/Sounds (e.g. "Glass")
--icon <path> Path to image file to attach as icon
-h, --help Show this help message

When launched with no arguments (e.g. by macOS for a stale notification
click), the app runs briefly to handle the pending click action, then exits.
""")
}

// Parse arguments
var params = NotificationParams()
var message: String?

// Determine if any user-facing flags were passed (ignoring -psn_* from LaunchServices).
let hasUserFlags = CommandLine.arguments.dropFirst().contains { !$0.hasPrefix("-psn_") }

var i = 1
let args = CommandLine.arguments
while i < args.count {
Expand Down Expand Up @@ -207,34 +221,43 @@ while i < args.count {
printUsage()
exit(0)
default:
// LaunchServices may pass -psn_* when launching a .app bundle; ignore it.
if args[i].hasPrefix("-psn_") { break }
fputs("Error: unknown option '\(args[i])'\n", stderr)
printUsage()
exit(1)
}
i += 1
}

guard let message = message else {
fputs("Error: -m (message) is required\n", stderr)
printUsage()
exit(1)
}
params.message = message

// Launch application
// Launch application and register delegate BEFORE validating -m.
// When macOS relaunches the app for a stale notification click, no arguments
// are passed. The delegate must be ready so didReceive can handle the click.
let app = NSApplication.shared
app.setActivationPolicy(.accessory)

let delegate = NotificationDelegate()
UNUserNotificationCenter.current().delegate = delegate

sendNotification(params)
if let message = message {
params.message = message
sendNotification(params)

// Terminate after timeout; use a shorter timeout when no click action is registered
let hasAction = params.execute != nil || params.activate != nil
let timeout: TimeInterval = hasAction ? 60.0 : 5.0
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
NSApplication.shared.terminate(nil)
// Terminate after timeout; use a shorter timeout when no click action is registered
let hasAction = params.execute != nil || params.activate != nil
let timeout: TimeInterval = hasAction ? 60.0 : 5.0
terminationWork = DispatchWorkItem { NSApplication.shared.terminate(nil) }
DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: terminationWork!)
} else if !hasUserFlags {
// Launched by macOS for a notification click (no user flags, possibly only -psn_*).
// Run briefly to let didReceive handle the pending click, then exit.
// didReceive cancels this timer to avoid racing.
terminationWork = DispatchWorkItem { NSApplication.shared.terminate(nil) }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: terminationWork!)
} else {
fputs("Error: -m (message) is required\n", stderr)
printUsage()
exit(1)
}

app.run()
8 changes: 6 additions & 2 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ run_test_output() {
# Test: -h shows usage and exits 0
run_test_output "-h shows help and exits 0" 0 "Usage" -h

# Test: missing -m exits 1
run_test_output "missing -m exits 1" 1 "required"
# Test: no arguments enters notification-click handler mode (exits 0)
# When macOS relaunches the app for a stale notification click, no args are passed.
run_test "no arguments exits 0 (notification click handler)" 0

# Test: has flags but missing -m exits 1
run_test_output "flags without -m exits 1" 1 "required" -t "Title"

# Test: unknown option exits 1
run_test_output "unknown option exits 1" 1 "unknown" --unknown-flag
Expand Down