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

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{} }