feat: init commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
.build/
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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 1–120 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`.
|
||||
Executable
+49
@@ -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"
|
||||
@@ -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 (1–120)")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}'
|
||||
""")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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
|
||||
Reference in New Issue
Block a user