build: vendor winctx/cal-events swift helpers with graceful-skip make target
967daa44dc9203cc5f130f14067e495e4e920b58
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 13:02
parent af7bbc4a
3 files changed
Makefile +13 −2
@@ -1,9 +1,20 @@
-.PHONY: build test install vet
+.PHONY: build test install vet helpers
build:
go build -o bin/tt ./cmd/ticktock
test:
go test ./...
vet:
go vet ./...
-install: build
+# Native Swift helpers: winctx (AX window titles) and cal-events (EventKit).
+# Skips gracefully without swiftc — the daemon falls back to System Events
+# titles and the timeline simply shows no calendar lane.
+helpers:
+ @if command -v swiftc >/dev/null 2>&1; then \
+ mkdir -p bin; \
+ swiftc -O native/winctx.swift -o bin/winctx && echo "built bin/winctx"; \
+ swiftc -O native/cal-events.swift -o bin/cal-events && echo "built bin/cal-events"; \
+ else \
+ echo "swiftc not found — skipping native helpers (titles fall back to System Events; no calendar lane)"; \
+ fi
+install: build helpers
ln -sfn "$(PWD)/bin/tt" "$(HOME)/.local/bin/tt"
native/cal-events.swift +65 −0
@@ -0,0 +1,65 @@
+#!/usr/bin/env swift
+// cal-events — dump calendar events for a date range as JSON, for tock logging.
+//
+// Usage: cal-events <from YYYY-MM-DD> [<to YYYY-MM-DD>] (to defaults to from)
+// Output: JSON array of {title, start, end, calendar, allDay} with ISO-8601
+// local-time start/end. Non-all-day events only. All event calendars included;
+// filter downstream (the day tool defaults to the work calendar).
+
+import EventKit
+import Foundation
+
+let args = CommandLine.arguments
+guard args.count >= 2 else {
+ fputs("usage: cal-events <from YYYY-MM-DD> [to YYYY-MM-DD]\n", stderr)
+ exit(2)
+}
+
+let dateOnly = DateFormatter()
+dateOnly.dateFormat = "yyyy-MM-dd"
+dateOnly.timeZone = TimeZone.current
+guard let fromDate = dateOnly.date(from: args[1]) else {
+ fputs("bad from-date: \(args[1])\n", stderr); exit(2)
+}
+let toDate = args.count >= 3 ? (dateOnly.date(from: args[2]) ?? fromDate) : fromDate
+
+let cal = Calendar.current
+let start = cal.startOfDay(for: fromDate)
+let end = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: toDate))!
+
+let store = EKEventStore()
+let sema = DispatchSemaphore(value: 0)
+var output = "[]"
+
+store.requestFullAccessToEvents { granted, _ in
+ defer { sema.signal() }
+ guard granted else {
+ fputs("Calendar access denied — grant in System Settings > Privacy > Calendars\n", stderr)
+ return
+ }
+ let iso = ISO8601DateFormatter()
+ iso.timeZone = TimeZone.current
+ iso.formatOptions = [.withInternetDateTime]
+
+ let pred = store.predicateForEvents(withStart: start, end: end, calendars: nil)
+ let events = store.events(matching: pred)
+ .filter { !$0.isAllDay }
+ .sorted { $0.startDate < $1.startDate }
+
+ func esc(_ s: String) -> String {
+ var r = s.replacingOccurrences(of: "\\", with: "\\\\")
+ r = r.replacingOccurrences(of: "\"", with: "\\\"")
+ r = r.replacingOccurrences(of: "\n", with: " ")
+ return r
+ }
+ let items = events.map { e -> String in
+ let title = esc(e.title ?? "(no title)")
+ let calT = esc(e.calendar.title)
+ return " {\"title\": \"\(title)\", \"start\": \"\(iso.string(from: e.startDate))\", " +
+ "\"end\": \"\(iso.string(from: e.endDate))\", \"calendar\": \"\(calT)\"}"
+ }
+ output = items.isEmpty ? "[]" : "[\n" + items.joined(separator: ",\n") + "\n]"
+}
+
+sema.wait()
+print(output)
native/winctx.swift +50 −0
@@ -0,0 +1,50 @@
+#!/usr/bin/env swift
+// winctx — print the frontmost app + focused-window title as JSON, for autotrack.
+//
+// Uses the Accessibility (AX) API, which exposes window titles that AppleScript's
+// "System Events … name of front window" misses for many apps (Electron, some
+// native apps). Falls back to an empty title when AX is denied or the app has
+// none — the daemon then tries its osascript path.
+//
+// Output: {"app":"<name>","title":"<focused window title>"}
+// Requires Accessibility permission (System Settings → Privacy → Accessibility)
+// for the process that runs it; without it, title comes back empty (app still works).
+
+import Cocoa
+import ApplicationServices
+
+func jsonEsc(_ s: String) -> String {
+ var o = ""
+ for u in s.unicodeScalars {
+ switch u {
+ case "\"": o += "\\\""
+ case "\\": o += "\\\\"
+ case "\n": o += "\\n"
+ case "\r": o += "\\r"
+ case "\t": o += "\\t"
+ default:
+ if u.value < 0x20 { o += String(format: "\\u%04x", u.value) }
+ else { o.unicodeScalars.append(u) }
+ }
+ }
+ return o
+}
+
+func focusedTitle(_ pid: pid_t) -> String {
+ let axApp = AXUIElementCreateApplication(pid)
+ var winRef: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &winRef)
+ == .success, let win = winRef else { return "" }
+ var titleRef: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(win as! AXUIElement, kAXTitleAttribute as CFString,
+ &titleRef) == .success, let t = titleRef as? String else { return "" }
+ return t
+}
+
+guard let app = NSWorkspace.shared.frontmostApplication else {
+ print("{\"app\":\"\",\"title\":\"\"}")
+ exit(0)
+}
+let name = app.localizedName ?? ""
+let title = focusedTitle(app.processIdentifier)
+print("{\"app\":\"\(jsonEsc(name))\",\"title\":\"\(jsonEsc(title))\"}")