feat: init commit

This commit is contained in:
2026-07-06 01:17:30 +08:00
commit ea960f2dfa
14 changed files with 1211 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
.build/
+15
View File
@@ -0,0 +1,15 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "notification-service",
platforms: [
.macOS(.v12)
],
targets: [
.executableTarget(
name: "NotificationService",
path: "Sources/NotificationService"
)
]
)
+86
View File
@@ -0,0 +1,86 @@
# notification-service
A tiny macOS service that listens on `http://127.0.0.1:11270` and pops up a
centered modal window in response to an HTTP POST. Pure Swift + AppKit, no
third-party dependencies.
## Build
```bash
swift build -c release # or: just build
```
## Quick test
With the service running (`just install` or `just run`):
```bash
just test # pop a default notification
just show "Deploy done" "Build #42 passed" # pop a custom one
```
## Run (foreground)
```bash
.build/release/NotificationService run # or: just run
```
## Install as a login item
Installs a per-user LaunchAgent so the service starts at login and stays alive:
```bash
.build/release/NotificationService install # or: just install
```
- Binary → `~/Library/Application Support/NotificationService/notification-service`
- LaunchAgent → `~/Library/LaunchAgents/com.hattergit.notification-service.plist`
- Logs → `/tmp/notification-service.log`, `/tmp/notification-service.err`
Remove it with:
```bash
.build/release/NotificationService uninstall # or: just uninstall
```
## API
### `POST /api/v1/show`
Pops up a modal window centered on the active screen.
```bash
curl -X POST http://127.0.0.1:11270/api/v1/show \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","content":"World"}'
```
- `title` — window title (defaults to `"Notification"` if empty/omitted)
- `content` — body text (defaults to empty)
Response:
```json
{"status":"queued"}
```
The window is queued and shown immediately; if a window is already on screen,
the new one is shown as soon as the current one is dismissed. Each window has:
- **Dismiss** — close it.
- **Remind Me** — snooze: re-show the same title/content after a delay you pick
(default 5 minutes, range 1120 via the field/stepper). Snoozed reminders are
persisted to `~/Library/Application Support/NotificationService/snoozed.json`
and survive a service restart; anything already overdue is shown immediately
on next launch.
Other paths/methods return `404` / `405`; invalid JSON returns `400`.
## Notes
- Bound only to the loopback interface — not reachable from the network.
- Runs as a background (accessory) app: no Dock icon, no menu bar entry.
- The alert's app icon is the bell logo (`bell-logo.avif`), with the bottom
black bar removed and the bell centered on a transparent square. To
regenerate the embedded icon (e.g. after changing the logo), run
`just icon` — see `Scripts/gen_icon.sh`.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Regenerate Sources/NotificationService/AppIcon.swift from bell-logo.avif:
# removes the bottom black bar, trims white padding, centers the bell on a
# transparent 1024x1024 square, and embeds the PNG as base64 in Swift.
set -euo pipefail
cd "$(dirname "$0")/.."
SRC="${1:-bell-logo.avif}"
OUT_SWIFT="Sources/NotificationService/AppIcon.swift"
if [ ! -f "$SRC" ]; then
echo "source image not found: $SRC" >&2
exit 1
fi
PNG="$(mktemp -t bellicon).png"
trap 'rm -f "$PNG"' EXIT
swift Scripts/process_icon.swift "$SRC" "$PNG" >/dev/null
B64="$(base64 -i "$PNG" | tr -d '\n')"
cat > "$OUT_SWIFT" <<'EOF'
import Foundation
import AppKit
// Auto-generated from bell-logo.avif (bottom black bar removed, bell centered
// on a transparent square). Regenerate via `just icon`. DO NOT edit by hand.
enum AppIcon {
static let base64EncodedPNG = "__BASE64__"
static let image: NSImage? = {
guard let data = Data(base64Encoded: base64EncodedPNG),
let img = NSImage(data: data) else { return nil }
return img
}()
}
EOF
python3 - "$B64" "$OUT_SWIFT" <<'PY'
import sys
b64, path = sys.argv[1], sys.argv[2]
s = open(path).read().replace("__BASE64__", b64)
open(path, "w").write(s)
PY
echo "Generated $OUT_SWIFT ($(wc -c < "$OUT_SWIFT") bytes) from $SRC"
+121
View File
@@ -0,0 +1,121 @@
// Processes bell-logo.avif into a clean 1024x1024 app icon:
// 1. crop off the solid black bar at the bottom,
// 2. flood-fill the white background -> transparent (seeded from the borders,
// so white trapped inside the bell is preserved),
// 3. trim to the bell's bounding box and center it (with ~8% margin) on a
// transparent square canvas.
//
// Usage: swift process_icon.swift <input.avif|png> <output.png>
import Foundation
import AppKit
let inPath = CommandLine.arguments[1]
let outPath = CommandLine.arguments[2]
let canvasSide = 1024
guard let img = NSImage(contentsOfFile: inPath) else { fatalError("load \(inPath)") }
let W = Int(img.size.width), H = Int(img.size.height)
// Render the source to an RGBA buffer (top-left origin).
let full = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: W, pixelsHigh: H,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 32)!
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: full)
img.draw(in: NSRect(x: 0, y: 0, width: W, height: H))
NSGraphicsContext.restoreGraphicsState()
guard let base = full.bitmapData else { fatalError("bitmap") }
let bpr = full.bytesPerRow
func fpx(_ x: Int, _ y: Int) -> (Int, Int, Int, Int) {
let i = y * bpr + x * 4
return (Int(base[i]), Int(base[i + 1]), Int(base[i + 2]), Int(base[i + 3]))
}
// 1. Find the bottom black bar: topmost row of the contiguous black band at the
// bottom of the image (top-origin, so we scan from y=H-1 upward).
var blackStart = H
for y in (0..<H).reversed() {
var bl = 0
for x in 0..<W { let c = fpx(x, y); if c.3 > 128 && max(c.0, max(c.1, c.2)) < 40 { bl += 1 } }
if bl * 2 > W { blackStart = y } else if blackStart < H { break }
}
let cropH = blackStart // keep rows 0..<cropH
print("source \(W)x\(H); cropping black bar (rows \(cropH)..<\(H)) -> \(W)x\(cropH)")
// 2. Copy the kept rows into a flat RGBA buffer (top-origin).
let cbpr = W * 4
var cropped = [UInt8](repeating: 0, count: W * cropH * 4)
for y in 0..<cropH {
let srcRow = y * bpr
let dstRow = y * cbpr
for x in 0..<(W * 4) { cropped[dstRow + x] = base[srcRow + x] }
}
// Flood-fill white background -> transparent, seeded from all four borders.
// "White" = all channels > 232 (pure white bg); the red bell never qualifies.
@inline(__always) func isWhite(_ x: Int, _ y: Int) -> Bool {
let i = y * cbpr + x * 4
return cropped[i + 3] != 0 && Int(cropped[i]) > 232 && Int(cropped[i + 1]) > 232 && Int(cropped[i + 2]) > 232
}
var stack: [(Int, Int)] = []
func seed(_ x: Int, _ y: Int) {
guard x >= 0, x < W, y >= 0, y < cropH else { return }
let i = y * cbpr + x * 4
if cropped[i + 3] == 0 { return } // already transparent/visited
if isWhite(x, y) {
cropped[i + 3] = 0 // mark visited by making transparent
stack.append((x, y))
}
}
for x in 0..<W { seed(x, 0); seed(x, cropH - 1) }
for y in 0..<cropH { seed(0, y); seed(W - 1, y) }
while let (x, y) = stack.popLast() {
seed(x + 1, y); seed(x - 1, y); seed(x, y + 1); seed(x, y - 1)
}
// 3. Bounding box of surviving (non-transparent) pixels.
var minX = W, maxX = -1, minY = cropH, maxY = -1
for y in 0..<cropH {
for x in 0..<W {
if cropped[y * cbpr + x * 4 + 3] > 10 {
if x < minX { minX = x }
if x > maxX { maxX = x }
if y < minY { minY = y }
if y > maxY { maxY = y }
}
}
}
let bw = maxX - minX + 1, bh = maxY - minY + 1
print("bell bbox x=\(minX)..\(maxX) y=\(minY)..\(maxY) size=\(bw)x\(bh)")
// Wrap the cropped buffer in a bitmap rep -> CGImage, then crop to the bell.
let cropRep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: W, pixelsHigh: cropH,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: cbpr, bitsPerPixel: 32)!
for i in 0..<cropped.count { cropRep.bitmapData![i] = cropped[i] }
guard let cropCG = cropRep.cgImage,
let bellCG = cropCG.cropping(to: CGRect(x: minX, y: minY, width: bw, height: bh)) else {
fatalError("cgImage")
}
// 4. Center + scale the bell into a transparent 1024x1024 canvas.
let out = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: canvasSide, pixelsHigh: canvasSide,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 32)!
for i in 0..<(canvasSide * canvasSide * 4) { out.bitmapData![i] = 0 }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: out)
let margin = 0.08
let avail = Double(canvasSide) * (1 - 2 * margin)
let scale = min(avail / Double(bw), avail / Double(bh))
let dw = Int(Double(bw) * scale)
let dh = Int(Double(bh) * scale)
let dx = (canvasSide - dw) / 2
let dy = (canvasSide - dh) / 2
NSGraphicsContext.current?.cgContext.draw(bellCG, in: CGRect(x: dx, y: dy, width: dw, height: dh))
NSGraphicsContext.restoreGraphicsState()
guard let pngData = out.representation(using: .png, properties: [:]) else { fatalError("png") }
try! pngData.write(to: URL(fileURLWithPath: outPath))
print("wrote \(outPath) \(canvasSide)x\(canvasSide) (transparent bg, bell centered)")
@@ -0,0 +1,108 @@
import Foundation
import AppKit
/// Owns the HTTP listener and the modal-alert queue. Alerts are serialized so
/// only one is on screen at a time; additional POSTs are queued and shown in
/// order once the current one is dismissed.
final class AppDelegate: NSObject, NSApplicationDelegate {
private struct Pending {
let title: String
let content: String
}
private let host = "127.0.0.1"
private let port: UInt16 = 11270
private var server: HTTPServer?
private var pending: [Pending] = []
private var showing = false
func applicationDidFinishLaunching(_ notification: Notification) {
if let icon = AppIcon.image {
NSApp.applicationIconImage = icon
}
SnoozeStore.shared.handler = { [weak self] title, content in
self?.enqueue(title: title, content: content)
}
SnoozeStore.shared.restore()
let server = HTTPServer(host: host, port: port) { [weak self] req in
return self?.handle(req) ?? HTTPResponse(status: 500, body: "{\"error\":\"internal error\"}")
}
do {
try server.start()
self.server = server
NSLog("[notification-service] listening on http://%@:%d", host, Int(port))
} catch {
NSLog("[notification-service] failed to start: %@", "\(error)")
NSApp.terminate(nil)
}
}
func applicationWillTerminate(_ notification: Notification) {
server?.stop()
}
// MARK: - HTTP handling
private func handle(_ req: HTTPRequest) -> HTTPResponse {
// Normalize path (strip query string) for routing.
let path = req.path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? req.path
guard req.method == "POST" else {
return HTTPResponse(status: 405, body: "{\"error\":\"method not allowed\"}")
}
guard path == "/api/v1/show" else {
return HTTPResponse(status: 404, body: "{\"error\":\"not found\"}")
}
struct Body: Decodable {
var title: String?
var content: String?
}
let decoded: Body
do {
decoded = try JSONDecoder().decode(Body.self, from: req.body)
} catch {
return HTTPResponse(status: 400, body: "{\"error\":\"invalid JSON body\"}")
}
let title = decoded.title?.isEmpty == false ? decoded.title! : "Notification"
let content = decoded.content ?? ""
let captured = (title: title, content: content)
DispatchQueue.main.async { [weak self] in
self?.enqueue(title: captured.title, content: captured.content)
}
return HTTPResponse(status: 200, body: "{\"status\":\"queued\"}")
}
// MARK: - Alert queue
private func enqueue(title: String, content: String) {
pending.append(Pending(title: title, content: content))
showNext()
}
private func showNext() {
guard !showing, let next = pending.first else { return }
pending.removeFirst()
showing = true
let panel = NotificationPanel(title: next.title, content: next.content, defaultMinutes: 5)
panel.windowLevel = .floating
let result = panel.runModal()
if result == .remind {
// Save the content and re-fire after the chosen delay. Strip any
// existing [snoozed] suffix so repeated snoozes don't accumulate it.
SnoozeStore.shared.schedule(title: SnoozeStore.stripSuffix(next.title),
content: next.content,
minutes: panel.minutes)
}
showing = false
showNext()
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,231 @@
import Foundation
import Darwin
struct HTTPRequest {
let method: String
let path: String
let headers: [String: String]
let body: Data
}
struct HTTPResponse {
var status: Int = 200
var headers: [String: String] = ["Content-Type": "application/json"]
var body: String = ""
init(status: Int = 200, body: String = "") {
self.status = status
self.body = body
}
}
enum HTTPServerError: Error, CustomStringConvertible {
case socketFailed
case bindFailed(port: UInt16)
case listenFailed
var description: String {
switch self {
case .socketFailed: return "socket() failed"
case .bindFailed(let port): return "bind() failed on 127.0.0.1:\(port) (already running?)"
case .listenFailed: return "listen() failed"
}
}
}
/// Minimal single-threaded-per-connection HTTP/1.1 server bound to a specific
/// local address. Uses BSD sockets + GCD so there are no third-party deps and
/// the bind address can be locked to the loopback interface.
final class HTTPServer {
private let host: String
private let port: UInt16
private let handler: (HTTPRequest) -> HTTPResponse
private var listenFd: Int32 = -1
private var listenSource: DispatchSourceRead?
private let queue = DispatchQueue(label: "notification-service.http.listener")
init(host: String, port: UInt16, handler: @escaping (HTTPRequest) -> HTTPResponse) {
self.host = host
self.port = port
self.handler = handler
}
func start() throws {
// Writing to a socket whose peer has closed should not kill us.
signal(SIGPIPE, SIG_IGN)
listenFd = socket(AF_INET, SOCK_STREAM, 0)
if listenFd < 0 { throw HTTPServerError.socketFailed }
var reuse: Int32 = 1
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size(ofValue: reuse)))
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = port.bigEndian // htons
addr.sin_addr.s_addr = inet_addr(host) // 127.0.0.1 -> INADDR_LOOPBACK (network order)
let bindResult = withUnsafePointer(to: &addr) { ptr -> Int32 in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(listenFd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if bindResult < 0 { throw HTTPServerError.bindFailed(port: port) }
if listen(listenFd, 16) < 0 { throw HTTPServerError.listenFailed }
// Non-blocking listener driven by a GCD read source.
let flags = fcntl(listenFd, F_GETFL, 0)
_ = fcntl(listenFd, F_SETFL, flags | O_NONBLOCK)
let fd = listenFd
let source = DispatchSource.makeReadSource(fileDescriptor: listenFd, queue: queue)
source.setEventHandler { [weak self] in self?.acceptConnections() }
source.setCancelHandler { close(fd) }
source.resume()
listenSource = source
}
func stop() {
listenSource?.cancel()
listenSource = nil
}
// MARK: - Accept loop
private func acceptConnections() {
while true {
let clientFd = accept(listenFd, nil, nil)
if clientFd < 0 {
// EWOULDBLOCK/EAGAIN means we've drained the backlog.
if errno == EWOULDBLOCK || errno == EAGAIN { return }
return
}
let ownFd = clientFd
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.handleConnection(fd: ownFd)
}
}
}
private func handleConnection(fd: Int32) {
defer { close(fd) }
guard let request = readRequest(fd: fd) else {
sendResponse(fd: fd, response: HTTPResponse(status: 400, body: "{\"error\":\"bad request\"}"))
return
}
let response = handler(request)
sendResponse(fd: fd, response: response)
}
// MARK: - Request parsing
private func readRequest(fd: Int32) -> HTTPRequest? {
var buffer = Data()
var tmp = [UInt8](repeating: 0, count: 4096)
let separator = Data([0x0D, 0x0A, 0x0D, 0x0A]) // \r\n\r\n
while true {
let n = tmp.withUnsafeMutableBufferPointer { buf -> Int in
guard let base = buf.baseAddress else { return -1 }
return recv(fd, UnsafeMutableRawPointer(base), Int(buf.count), 0)
}
if n < 0 {
if errno == EINTR { continue }
return nil
}
if n == 0 { break } // peer closed
buffer.append(contentsOf: tmp[0..<n])
if let sepRange = buffer.range(of: separator) {
let headerEnd = sepRange.lowerBound
let bodyStart = sepRange.upperBound
let headerStr = String(data: buffer.subdata(in: 0..<headerEnd), encoding: .utf8) ?? ""
let headers = Self.parseHeaders(headerStr)
let contentLength = Int(headers["content-length"] ?? "0") ?? 0
// Keep reading until the full body is in the buffer.
while buffer.count - bodyStart < contentLength {
let m = tmp.withUnsafeMutableBufferPointer { buf -> Int in
guard let base = buf.baseAddress else { return -1 }
return recv(fd, UnsafeMutableRawPointer(base), Int(buf.count), 0)
}
if m < 0 {
if errno == EINTR { continue }
return nil
}
if m == 0 { break }
buffer.append(contentsOf: tmp[0..<m])
}
let bodyEnd = min(bodyStart + contentLength, buffer.count)
let body = buffer.subdata(in: bodyStart..<bodyEnd)
return Self.parseRequest(header: headerStr, body: body)
}
}
return nil
}
private static func parseRequest(header: String, body: Data) -> HTTPRequest? {
let lines = header.components(separatedBy: "\r\n")
guard let firstLine = lines.first else { return nil }
let parts = firstLine.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
guard parts.count >= 2 else { return nil }
let method = String(parts[0])
let path = String(parts[1])
let headers = parseHeaders(header)
return HTTPRequest(method: method, path: path, headers: headers, body: body)
}
private static func parseHeaders(_ header: String) -> [String: String] {
var headers: [String: String] = [:]
let lines = header.components(separatedBy: "\r\n")
for line in lines.dropFirst() {
guard let colonIdx = line.firstIndex(of: ":") else { continue }
let key = String(line[..<colonIdx]).trimmingCharacters(in: .whitespaces).lowercased()
let val = String(line[line.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
headers[key] = val
}
return headers
}
// MARK: - Response
private func sendResponse(fd: Int32, response: HTTPResponse) {
let bodyData = response.body.data(using: .utf8) ?? Data()
var header = "HTTP/1.1 \(response.status) \(Self.statusText(response.status))\r\n"
for (k, v) in response.headers { header += "\(k): \(v)\r\n" }
header += "Content-Length: \(bodyData.count)\r\n"
header += "Connection: close\r\n"
header += "\r\n"
var payload = header.data(using: .utf8) ?? Data()
payload.append(bodyData)
payload.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
guard let base = ptr.baseAddress else { return }
var sent = 0
while sent < payload.count {
let n = send(fd, base.advanced(by: sent), payload.count - sent, 0)
if n <= 0 {
if n < 0 && errno == EINTR { continue }
break
}
sent += n
}
}
}
private static func statusText(_ status: Int) -> String {
switch status {
case 200: return "OK"
case 201: return "Created"
case 400: return "Bad Request"
case 404: return "Not Found"
case 405: return "Method Not Allowed"
case 500: return "Internal Server Error"
default: return "Status"
}
}
}
@@ -0,0 +1,138 @@
import Foundation
import Darwin
/// Installs/uninstalls a per-user LaunchAgent so the service starts at login.
enum LaunchAgent {
static let label = "com.hattergit.notification-service"
static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static var installURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotificationService/notification-service")
}
static func install() {
let fm = FileManager.default
// Resolve the absolute path of the currently-running binary.
let execPath = Self.realpath(CommandLine.arguments[0]) ?? CommandLine.arguments[0]
let execURL = URL(fileURLWithPath: execPath)
let dest = installURL
try? fm.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true)
if fm.fileExists(atPath: dest.path) {
do { try fm.removeItem(at: dest) }
catch {
fail("install: could not remove existing binary: \(error)")
}
}
do {
try fm.copyItem(at: execURL, to: dest)
try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dest.path)
} catch {
fail("install: could not copy binary to \(dest.path): \(error)")
}
let plist = generatePlist(execPath: dest.path)
do {
try plist.write(to: plistURL, atomically: true, encoding: .utf8)
} catch {
fail("install: could not write plist to \(plistURL.path): \(error)")
}
let gui = "gui/\(getuid())"
// Unload any previously-loaded instance first (ignore errors).
runLaunchctl(["bootout", "\(gui)/\(label)"])
let result = runLaunchctlCaptured(["bootstrap", gui, plistURL.path])
if result.status != 0 {
print("install: launchctl bootstrap failed (exit \(result.status)).")
if !result.stderr.isEmpty {
print(" \(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
}
print(" The plist is installed and will start at next login.")
} else {
print("Installed and started.")
}
print(" binary: \(dest.path)")
print(" plist: \(plistURL.path)")
print(" logs: /tmp/notification-service.log, /tmp/notification-service.err")
print(" listen: http://127.0.0.1:11270")
}
static func uninstall() {
let gui = "gui/\(getuid())"
runLaunchctl(["bootout", "\(gui)/\(label)"])
let fm = FileManager.default
try? fm.removeItem(at: plistURL)
try? fm.removeItem(at: installURL)
print("Uninstalled. (LaunchAgent stopped, plist and binary removed.)")
}
private static func generatePlist(execPath: String) -> String {
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>\(label)</string>
<key>ProgramArguments</key>
<array>
<string>\(execPath)</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/notification-service.log</string>
<key>StandardErrorPath</key>
<string>/tmp/notification-service.err</string>
</dict>
</plist>
"""
}
@discardableResult
private static func runLaunchctl(_ args: [String]) -> Int32 {
runLaunchctlCaptured(args).status
}
private static func runLaunchctlCaptured(_ args: [String]) -> (status: Int32, stderr: String) {
let p = Process()
// launchctl moved from /usr/bin to /bin on recent macOS; resolve either.
let launchctlPath = ["/bin/launchctl", "/usr/bin/launchctl"]
.first { FileManager.default.fileExists(atPath: $0) } ?? "/bin/launchctl"
p.executableURL = URL(fileURLWithPath: launchctlPath)
p.arguments = args
// Use separate pipes for stdout/stderr — sharing one FileHandle for
// both makes Process.run() throw.
let outPipe = Pipe()
let errPipe = Pipe()
p.standardOutput = outPipe
p.standardError = errPipe
do { try p.run() } catch {
return (-1, "could not run launchctl: \(error)")
}
p.waitUntilExit()
let errData = try? errPipe.fileHandleForReading.readToEnd()
return (p.terminationStatus, String(data: errData ?? Data(), encoding: .utf8) ?? "")
}
private static func fail(_ message: String) -> Never {
FileHandle.standardError.write((message + "\n").data(using: .utf8)!)
exit(1)
}
private static func realpath(_ path: String) -> String? {
guard let resolved = path.withCString({ Darwin.realpath($0, nil) }) else {
return nil
}
return String(cString: resolved)
}
}
@@ -0,0 +1,230 @@
import Foundation
import AppKit
/// A centered, rounded modal card that replaces the stock NSAlert for a
/// nicer-looking notification with a snooze control. Dark-mode aware via
/// semantic system colors; floats above other apps' windows.
final class NotificationPanel {
enum Result { case dismiss, remind }
private let panel: Panel
private let card: CardView
private let minutesField: NSTextField
private let stepper: NSStepper
private let remindButton: NSButton
private var result: Result = .dismiss
private(set) var minutes: Int
var windowLevel: NSWindow.Level {
get { panel.level }
set { panel.level = newValue }
}
init(title: String, content: String, defaultMinutes: Int) {
minutes = max(1, min(120, defaultMinutes))
panel = Panel(contentRect: NSRect(x: 0, y: 0, width: 380, height: 400),
styleMask: [], backing: .buffered, defer: false)
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = true
panel.isMovableByWindowBackground = false
panel.hidesOnDeactivate = false
panel.level = .floating
card = CardView()
card.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = card
card.widthAnchor.constraint(equalToConstant: 380).isActive = true
// ---- Subviews ----
let icon = NSImageView()
icon.image = AppIcon.image
icon.imageScaling = .scaleProportionallyUpOrDown
icon.translatesAutoresizingMaskIntoConstraints = false
icon.widthAnchor.constraint(equalToConstant: 44).isActive = true
icon.heightAnchor.constraint(equalToConstant: 44).isActive = true
let titleField = NSTextField(labelWithString: title)
titleField.font = .systemFont(ofSize: 15, weight: .semibold)
titleField.lineBreakMode = .byTruncatingTail
titleField.maximumNumberOfLines = 2
titleField.cell?.truncatesLastVisibleLine = true
titleField.translatesAutoresizingMaskIntoConstraints = false
titleField.setContentHuggingPriority(.defaultLow, for: .horizontal)
titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let header = NSStackView(views: [icon, titleField])
header.orientation = .horizontal
header.alignment = .centerY
header.spacing = 12
header.translatesAutoresizingMaskIntoConstraints = false
header.setContentHuggingPriority(.defaultLow, for: .horizontal)
let contentField = NSTextField(labelWithString: content)
contentField.font = .systemFont(ofSize: 13)
contentField.textColor = .secondaryLabelColor
contentField.lineBreakMode = .byWordWrapping
contentField.maximumNumberOfLines = 0
contentField.cell?.wraps = true
contentField.preferredMaxLayoutWidth = 0
contentField.translatesAutoresizingMaskIntoConstraints = false
contentField.setContentHuggingPriority(.defaultLow, for: .horizontal)
contentField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
minutesField = NSTextField(string: "\(minutes)")
minutesField.font = .systemFont(ofSize: 12, weight: .medium)
minutesField.alignment = .center
minutesField.translatesAutoresizingMaskIntoConstraints = false
minutesField.widthAnchor.constraint(equalToConstant: 38).isActive = true
let fmt = NumberFormatter()
fmt.numberStyle = .none
fmt.usesGroupingSeparator = false
fmt.minimum = 1
fmt.maximum = 120
fmt.isLenient = true
minutesField.formatter = fmt
stepper = NSStepper()
stepper.minValue = 1
stepper.maxValue = 120
stepper.increment = 1
stepper.doubleValue = Double(minutes)
stepper.valueWraps = false
stepper.autorepeat = true
stepper.translatesAutoresizingMaskIntoConstraints = false
let snoozeLabel = NSTextField(labelWithString: "Remind me in")
snoozeLabel.font = .systemFont(ofSize: 12)
snoozeLabel.textColor = .secondaryLabelColor
snoozeLabel.translatesAutoresizingMaskIntoConstraints = false
let minLabel = NSTextField(labelWithString: "minutes (1120)")
minLabel.font = .systemFont(ofSize: 12)
minLabel.textColor = .secondaryLabelColor
minLabel.translatesAutoresizingMaskIntoConstraints = false
let snoozeRow = NSStackView(views: [snoozeLabel, minutesField, stepper, minLabel])
snoozeRow.orientation = .horizontal
snoozeRow.alignment = .centerY
snoozeRow.spacing = 8
snoozeRow.translatesAutoresizingMaskIntoConstraints = false
snoozeRow.heightAnchor.constraint(equalToConstant: 24).isActive = true
let dismissButton = NSButton(title: "Dismiss", target: nil, action: nil)
dismissButton.bezelStyle = .rounded
dismissButton.keyEquivalent = "\u{1b}" // Esc
dismissButton.translatesAutoresizingMaskIntoConstraints = false
remindButton = NSButton(title: "Remind Me", target: nil, action: nil)
remindButton.bezelStyle = .rounded
remindButton.keyEquivalent = "\r" // Return -> default (blue)
remindButton.translatesAutoresizingMaskIntoConstraints = false
let spacer = NSView()
spacer.translatesAutoresizingMaskIntoConstraints = false
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let buttonRow = NSStackView(views: [spacer, dismissButton, remindButton])
buttonRow.orientation = .horizontal
buttonRow.alignment = .centerY
buttonRow.spacing = 10
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buttonRow.setContentHuggingPriority(.defaultLow, for: .horizontal)
// ---- Vertical stack ----
let stack = NSStackView(views: [header, contentField, snoozeRow, buttonRow])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
stack.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24),
card.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 24),
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
card.bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 24),
// span full width where needed
header.widthAnchor.constraint(equalTo: stack.widthAnchor),
contentField.widthAnchor.constraint(equalTo: stack.widthAnchor),
buttonRow.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
// Wire targets now that self is initialized.
stepper.target = self
stepper.action = #selector(stepperChanged)
minutesField.target = self
minutesField.action = #selector(fieldChanged)
dismissButton.target = self
dismissButton.action = #selector(dismissTapped)
remindButton.target = self
remindButton.action = #selector(remindTapped)
}
// MARK: - Run
func runModal() -> Result {
NSApp.activate(ignoringOtherApps: true)
card.layoutSubtreeIfNeeded()
let fit = card.fittingSize
panel.setContentSize(NSSize(width: 380, height: fit.height))
panel.center()
panel.makeFirstResponder(remindButton)
_ = NSApp.runModal(for: panel)
panel.orderOut(nil)
return result
}
// MARK: - Actions
@objc private func dismissTapped() {
result = .dismiss
NSApp.stopModal()
}
@objc private func remindTapped() {
panel.makeFirstResponder(nil) // commit field editing
minutes = clampedMinutes()
result = .remind
NSApp.stopModal()
}
@objc private func stepperChanged() {
minutesField.intValue = stepper.intValue
}
@objc private func fieldChanged() {
stepper.intValue = Int32(clampedMinutes())
}
private func clampedMinutes() -> Int {
var v = Int(minutesField.intValue)
if v < 1 { v = 1 }
if v > 120 { v = 120 }
return v
}
}
// MARK: - Window + card
private final class Panel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
/// Rounded, opaque-on-clear card background. Adapts to light/dark via system
/// colors; the surrounding window is transparent so corners show through.
private final class CardView: NSView {
override var isFlipped: Bool { true }
override func draw(_ dirty: NSRect) {
let path = NSBezierPath(roundedRect: bounds, xRadius: 16, yRadius: 16)
NSColor.windowBackgroundColor.setFill()
path.fill()
NSColor.separatorColor.withAlphaComponent(0.6).setStroke()
path.lineWidth = 1
path.stroke()
}
}
@@ -0,0 +1,114 @@
import Foundation
/// A persisted, scheduled re-reminder. When the user snoozes an alert, the
/// title/content are saved here with a due time and re-fired later even if
/// the service is restarted in between.
struct SnoozedReminder: Codable {
let id: UUID
let title: String
let content: String
let due: Date
}
/// Owns snoozed reminders. Reminders are persisted to a JSON file and driven
/// by `DispatchQueue.main.asyncAfter` timers. On launch, anything already
/// overdue is shown immediately and future ones are re-scheduled.
final class SnoozeStore {
static let shared = SnoozeStore()
/// Suffix appended to the title when a snoozed reminder is re-fired, so the
/// user can tell it's a delayed re-remind.
static let suffix = " [snoozed]"
/// Remove a trailing snooze suffix so re-snoozing doesn't accumulate them.
static func stripSuffix(_ title: String) -> String {
if title.hasSuffix(suffix) {
return String(title.dropLast(suffix.count))
}
return title
}
/// Invoked on the main thread when a reminder comes due. Set by AppDelegate
/// at launch, before `restore()` is called. The title passed already has
/// the `[snoozed]` suffix appended.
var handler: ((String, String) -> Void)?
private let queue = DispatchQueue(label: "notification-service.snooze")
private var reminders: [SnoozedReminder] = []
private var url: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotificationService/snoozed.json")
}
private init() {
if let data = try? Data(contentsOf: url),
let arr = try? JSONDecoder().decode([SnoozedReminder].self, from: data) {
reminders = arr
}
}
/// Schedule a re-reminder `minutes` minutes from now (clamped to 1...120).
func schedule(title: String, content: String, minutes: Int) {
let minutes = max(1, min(120, minutes))
let reminder = SnoozedReminder(
id: UUID(), title: title, content: content,
due: Date(timeIntervalSinceNow: TimeInterval(minutes) * 60))
let id = reminder.id
let delay = reminder.due.timeIntervalSinceNow
queue.async {
self.reminders.append(reminder)
self.persist()
}
DispatchQueue.main.asyncAfter(deadline: .now() + max(0, delay)) { [weak self] in
self?.fire(id: id)
}
}
/// Re-fire a scheduled reminder by id (no-op if it was already removed).
private func fire(id: UUID) {
var fired: SnoozedReminder?
queue.sync {
if let idx = reminders.firstIndex(where: { $0.id == id }) {
fired = reminders.remove(at: idx)
persist()
}
}
if let r = fired {
let h = handler
DispatchQueue.main.async { h?(r.title + Self.suffix, r.content) }
}
}
/// Call at launch: show anything already overdue, re-arm the rest.
func restore() {
queue.async {
let now = Date()
let overdue = self.reminders.filter { $0.due <= now }
let future = self.reminders.filter { $0.due > now }
self.reminders = future
self.persist()
let h = self.handler
for r in overdue {
DispatchQueue.main.async { h?(r.title + Self.suffix, r.content) }
}
for r in future {
let id = r.id
let delay = r.due.timeIntervalSinceNow
DispatchQueue.main.asyncAfter(deadline: .now() + max(0, delay)) { [weak self] in
self?.fire(id: id)
}
}
}
}
private func persist() {
// Called on `queue`.
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
if let data = try? JSONEncoder().encode(reminders) {
try? data.write(to: url)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import Foundation
import AppKit
// MARK: - Entry point
let args = CommandLine.arguments
let command = args.count > 1 ? args[1] : "run"
switch command {
case "install":
LaunchAgent.install()
case "uninstall":
LaunchAgent.uninstall()
case "run":
runService()
case "-h", "--help", "help":
printUsage()
default:
FileHandle.standardError.write(("Unknown command: \(command)\n").data(using: .utf8)!)
printUsage()
exit(2)
}
func runService() {
let app = NSApplication.shared
// Accessory = no Dock icon, no menu bar app; just a background listener
// that can surface modal windows.
app.setActivationPolicy(.accessory)
let delegate = AppDelegate()
app.delegate = delegate
app.run()
}
func printUsage() {
let prog = (CommandLine.arguments[0] as NSString).lastPathComponent
print("""
notification-service — local modal-notification HTTP server
Usage:
\(prog) run Start the server (listens on http://127.0.0.1:11270)
\(prog) install Install as a per-user LaunchAgent (starts at login)
\(prog) uninstall Remove the LaunchAgent and binary
\(prog) help Show this help
API:
POST /api/v1/show
{ "title": "Window title", "content": "Body text" }
Example:
curl -X POST http://127.0.0.1:11270/api/v1/show \\
-H 'Content-Type: application/json' \\
-d '{"title":"Hello","content":"World"}'
""")
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+48
View File
@@ -0,0 +1,48 @@
# notification-service tasks (https://github.com/casey/just)
binary := ".build/release/NotificationService"
# default: show available recipes
default:
@just --list
# regenerate the embedded app icon from bell-logo.avif, then build
icon: build
./Scripts/gen_icon.sh bell-logo.avif
swift build -c release
# build the release binary
build:
swift build -c release
# run the service in the foreground
run: build
{{binary}} run
# install as a per-user LaunchAgent (starts at login)
install: build
{{binary}} install
# remove the LaunchAgent and binary
uninstall: build
{{binary}} uninstall
# pop a test notification window (service must be running: `just install` or `just run`)
test:
curl -s -X POST http://127.0.0.1:11270/api/v1/show \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","content":"World"}'
# pop a custom notification: just show "My Title" "My body text"
show title="Hello" content="World":
curl -s -X POST http://127.0.0.1:11270/api/v1/show \
-H 'Content-Type: application/json' \
-d '{"title":"{{title}}","content":"{{content}}"}'
# clean build artifacts
clean:
swift package clean
# show the binary's help text
help: build
@{{binary}} help 2>/dev/null || swift run NotificationService help