feat(autotrack): darwin OS sampling with !darwin stub and portable parsers
777891748abf40c8d7da6ce37b3bba1c35e9e41a
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:43
parent 3f432dc4
4 files changed
internal/autotrack/darwin.go +97 −0
@@ -0,0 +1,97 @@
+//go:build darwin
+
+package autotrack
+
+import (
+ "context"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// osTimeout bounds every OS shell-out so a wedged osascript can never stall
+// the poll loop (parity with the Python daemon's timeout=4).
+const osTimeout = 4 * time.Second
+
+// browsers maps app names to the osascript that reads their active-tab URL.
+var browsers = map[string]string{
+ "Safari": `tell application "Safari" to get URL of front document`,
+ "Google Chrome": `tell application "Google Chrome" to get URL of active tab of front window`,
+ "Brave Browser": `tell application "Brave Browser" to get URL of active tab of front window`,
+ "Microsoft Edge": `tell application "Microsoft Edge" to get URL of active tab of front window`,
+ "Arc": `tell application "Arc" to get URL of active tab of front window`,
+ "Dia": `tell application "Dia" to get URL of active tab of front window`,
+}
+
+// runCmd executes a command with the standard timeout, returning stdout
+// ("" on any failure — an OS hiccup must never panic the loop).
+func runCmd(name string, args ...string) string {
+ ctx, cancel := context.WithTimeout(context.Background(), osTimeout)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, name, args...).Output()
+ if err != nil {
+ return ""
+ }
+ return string(out)
+}
+
+func osa(script string) string { return strings.TrimSpace(runCmd("osascript", "-e", script)) }
+
+func frontApp() string {
+ return osa(`tell application "System Events" to get name of first application process whose frontmost is true`)
+}
+
+func frontTitle() string {
+ return osa(`tell application "System Events" to tell (first application process whose frontmost is true) to get name of front window`)
+}
+
+func browserURL(app string) string {
+ script, ok := browsers[app]
+ if !ok {
+ return ""
+ }
+ return osa(script)
+}
+
+func idleSecs() int { return parseHIDIdle(runCmd("ioreg", "-c", "IOHIDSystem")) }
+
+func screenLocked() bool {
+ return parseLocked(runCmd("ioreg", "-n", "Root", "-d1", "-r", "-k", "CGSSessionScreenIsLocked"))
+}
+
+// winctxApp reads the frontmost {app, title} via the bundled AX helper
+// (richer titles than System Events). Empties when the helper is missing,
+// denied, or errors — the caller falls back to osascript.
+func winctxApp() (string, string) {
+ bin := HelperPath("winctx")
+ if bin == "" {
+ return "", ""
+ }
+ out := strings.TrimSpace(runCmd(bin))
+ if out == "" {
+ return "", ""
+ }
+ return parseWinctx([]byte(out))
+}
+
+// readContext samples the current foreground context; nil when no app.
+// Prefers the AX helper's app+title, falling back to System Events per field.
+func readContext() *Ctx {
+ app, title := winctxApp()
+ if app == "" {
+ app = frontApp()
+ }
+ if app == "" {
+ return nil
+ }
+ if title == "" {
+ title = frontTitle()
+ }
+ u := browserURL(app)
+ return &Ctx{App: app, Title: title, URL: u, Domain: Domain(u)}
+}
+
+// ReadSample gathers one full OS observation for the poll loop.
+func ReadSample() Sample {
+ return Sample{Idle: idleSecs(), Raw: readContext(), Locked: screenLocked()}
+}
internal/autotrack/sample.go +74 −0
@@ -0,0 +1,74 @@
+package autotrack
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// Sample is one OS observation handed to the segmentation loop.
+type Sample struct {
+ Idle int // HID idle seconds
+ Raw *Ctx // foreground context; nil when no foreground app
+ Locked bool // screen locked (hard, immediate away signal)
+}
+
+// parseHIDIdle extracts HIDIdleTime (nanoseconds) from `ioreg -c IOHIDSystem`
+// output and returns whole seconds. 0 when absent or malformed.
+func parseHIDIdle(out string) int {
+ for _, line := range strings.Split(out, "\n") {
+ if !strings.Contains(line, "HIDIdleTime") {
+ continue
+ }
+ i := strings.LastIndex(line, "=")
+ if i < 0 {
+ continue
+ }
+ ns, err := strconv.ParseInt(strings.TrimSpace(line[i+1:]), 10, 64)
+ if err != nil {
+ continue
+ }
+ return int(ns / 1_000_000_000)
+ }
+ return 0
+}
+
+// parseLocked reports whether ioreg output shows the lock flag. The
+// CGSSessionScreenIsLocked key is present only while the screen is locked.
+func parseLocked(out string) bool {
+ return strings.Contains(strings.ReplaceAll(out, " ", ""), `"CGSSessionScreenIsLocked"=Yes`)
+}
+
+// parseWinctx decodes the winctx helper's {"app","title"} JSON. Empty on error.
+func parseWinctx(b []byte) (app, title string) {
+ var v struct {
+ App string `json:"app"`
+ Title string `json:"title"`
+ }
+ if err := json.Unmarshal(b, &v); err != nil {
+ return "", ""
+ }
+ return v.App, v.Title
+}
+
+// HelperPath finds a bundled native helper (winctx, cal-events): first next
+// to the symlink-resolved tt binary (make install symlinks ~/.local/bin/tt →
+// <repo>/bin/tt, and the helpers build into <repo>/bin), then on $PATH.
+// "" when not found — callers degrade gracefully.
+func HelperPath(name string) string {
+ if exe, err := os.Executable(); err == nil {
+ if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil {
+ p := filepath.Join(filepath.Dir(resolved), name)
+ if st, serr := os.Stat(p); serr == nil && !st.IsDir() {
+ return p
+ }
+ }
+ }
+ if p, err := exec.LookPath(name); err == nil {
+ return p
+ }
+ return ""
+}
internal/autotrack/sample_test.go +57 −0
@@ -0,0 +1,57 @@
+package autotrack
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestParseHIDIdle(t *testing.T) {
+ out := `+-o IOHIDSystem <class IOHIDSystem, id 0x100000456>
+ {
+ "HIDIdleTime" = 2216064029
+ }`
+ if got := parseHIDIdle(out); got != 2 { // 2216064029 ns → 2 s
+ t.Errorf("parseHIDIdle=%d, want 2", got)
+ }
+ if got := parseHIDIdle("no idle info here"); got != 0 {
+ t.Errorf("absent key should give 0, got %d", got)
+ }
+ if got := parseHIDIdle(`"HIDIdleTime" = garbage`); got != 0 {
+ t.Errorf("malformed value should give 0, got %d", got)
+ }
+}
+
+func TestParseLocked(t *testing.T) {
+ if !parseLocked(` "CGSSessionScreenIsLocked" = Yes`) {
+ t.Error("locked output should parse true")
+ }
+ if parseLocked("") || parseLocked(`"SomethingElse"=Yes`) {
+ t.Error("unlocked output should parse false")
+ }
+}
+
+func TestParseWinctx(t *testing.T) {
+ app, title := parseWinctx([]byte(`{"app":"Trace","title":"notes — repo"}`))
+ if app != "Trace" || title != "notes — repo" {
+ t.Errorf("got %q/%q", app, title)
+ }
+ if app, title := parseWinctx([]byte("garbage")); app != "" || title != "" {
+ t.Errorf("bad json should give empties, got %q/%q", app, title)
+ }
+}
+
+func TestHelperPathFallsBackToPATH(t *testing.T) {
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "fakehelper")
+ if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir)
+ if got := HelperPath("fakehelper"); got != bin {
+ t.Errorf("HelperPath=%q, want %q", got, bin)
+ }
+ if got := HelperPath("definitely-not-a-helper"); got != "" {
+ t.Errorf("missing helper should give empty, got %q", got)
+ }
+}
internal/autotrack/stub.go +8 −0
@@ -0,0 +1,8 @@
+//go:build !darwin
+
+package autotrack
+
+// ReadSample on non-darwin platforms returns an empty observation so the
+// package builds and its pure tests run everywhere; the daemon is
+// darwin-only.
+func ReadSample() Sample { return Sample{} }