▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0

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))\"}")