Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ SSH-to-self is a no-op on an already-locked Mac — do not special-case it.
- **Synergy client list:** `~/Library/Preferences/Synergy/synergy.conf`. This is the source of truth for which hosts to lock — do not maintain a parallel client list.
- **Synergy display names:** `~/Library/Preferences/Synergy/db.json` (sibling of the conf; overridable via `LOCK_SYNC_DB`). Synergy sanitizes screen names before writing the conf (e.g. it drops hyphens: `arich-mac` becomes the screen `arichmac-3181d4b4`), so the conf alone cannot reproduce a client's real hostname. `list-clients` recovers the true name from db.json's `computers[].name`, joined on the last 8 hex of `id` matching the conf's instance suffix. Read via `jq` (a soft dependency — macOS ships `/usr/bin/jq`; Homebrew paths are probed as a fallback). When jq or db.json is unavailable, it degrades to stripping the suffix. Use `name`, not `hostname` — the two diverge (`hostname` can be the default `Andrews-Mac-mini.local`).
- **Per-client username overrides:** `~/.config/lock-sync/config` (overridable via `LOCK_SYNC_CONFIG`). Plain text, whitespace-separated `<host> <user>`, `#` line comments. Unlisted hosts use `$USER`. Absent or empty file is a valid state.
- **Meeting-app suppression list:** `~/.config/lock-sync/guard-processes` (overridable via `LOCK_SYNC_GUARD_PROCESSES`). Same shape as the username-override config: plain text, one process name per line, `#` comments. Absent file uses the built-in default list (`zoom.us`, `Microsoft Teams`, `FaceTime`). Present file replaces the default list entirely (not merged).

## Runtime

Expand All @@ -40,6 +41,21 @@ Bash, shellcheck-clean (project follows the user's global bash standards in `~/.

All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`. The LaunchAgent auto-starts the watcher on login and writes its log to `~/Library/Logs/lock-sync.log`. Per-slice design docs live under `docs/plans/`.

**Upgrade path:** every machine acting as a Synergy client is a symmetric peer, not just the primary/server — `bin/install` must be re-run on EVERY client machine when upgrading, not only on the machine you're actively developing on. `lock-fanout` invokes `lock-guard` on each client via the absolute path `$HOME/.local/bin/lock-guard`, which only exists once `bin/install` has symlinked it there. Skipping install on any client leaves `lock-guard` missing on that client, and locking fails for it (ssh_exit=127 / command-not-found) until install is re-run there.

## lock-guard: one-time Chrome Automation permission

`lock-guard`'s Google Meet detection drives Chrome via `osascript`. The
first time this runs on a client, macOS prompts for Automation permission
(System Settings > Privacy & Security > Automation). Because `lock-guard`
runs non-interactively over SSH from launchd, there is no interactive
session to click "Allow" in — grant this manually once per client, by
running `bin/lock-guard` interactively at a local Terminal on that client
after install, approving the Chrome automation prompt when it appears. If
the permission is never granted, Meet-tab detection silently fails open
(treated as "no Meet tab found," never as a fatal error) — the process-list
and mic-active checks still work normally.

## Commands

- `bats tests/` — run the test suite. Local: `brew install bats-core`. CI: installs bats-core v1.10.0 from source to `$HOME/.local` (see `.github/workflows/ci.yml`).
Expand All @@ -48,7 +64,8 @@ All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`.
- `bin/uninstall` — bootout the agent, remove plist and symlinks. Preserves the log file. Idempotent.
- `bin/list-clients [path]` — slice (a). Parse Synergy conf, emit `<host>.local` hostnames, resolving real display names via the sibling `db.json` when available (see Key external inputs). No arg → reads `~/Library/Preferences/Synergy/synergy.conf`.
- `bin/lock-watcher` — slices (b)+(c1). Subscribe to `com.apple.sessionagent.screenIsLocked`; on each event emit `<ISO-8601> locked` and invoke `bin/lock-fanout`. Blocks until signaled.
- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `pmset displaysleepnow` per host, emits one `<ISO-8601> client=<host> user=<user> ssh_exit=<rc>` line per host.
- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `lock-guard` to each client (see the `bin/lock-guard` bullet below), emits one `<ISO-8601> client=<host> user=<user> ssh_exit=<rc>` line per host.
- `bin/lock-guard` — runs on each client (invoked remotely by `lock-fanout` in place of a bare `pmset displaysleepnow`). Skips the lock and logs why if the client looks like it's in a call: a known meeting app is running (`~/.config/lock-sync/guard-processes`), the microphone is actively in use, or a Google Meet room tab is open in Chrome. Emits `<ISO-8601> action=sleep` or `<ISO-8601> action=suppress reason=<reason>`.

## `.claude/` directory

Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ mimolette.local admin

Unlisted hosts fall back to `$USER`. Absent or empty file is a valid state.

## Configure meeting-app suppression (optional)

By default, `lock-guard` (running on each client) suppresses the lock if it
detects `zoom.us`, `Microsoft Teams`, or `FaceTime` running. To customize
this list, create `~/.config/lock-sync/guard-processes` (overridable via
`LOCK_SYNC_GUARD_PROCESSES`):

```text
# ~/.config/lock-sync/guard-processes
# one process name per line, `#` starts a line comment
zoom.us
Microsoft Teams
FaceTime
Slack
```

Absent file uses the built-in default. Present file replaces it entirely
(not merged).

## Chrome Automation permission (one-time setup)

`lock-guard`'s Google Meet detection requires macOS Automation permission.
On first install, run `bin/lock-guard` interactively at a Terminal on each
client to grant this permission:

```sh
bin/lock-guard
```

When prompted, navigate to System Settings > Privacy & Security > Automation
and allow Terminal (or your shell's name) to automate Chrome. Without this
permission, Meet-tab detection silently skips (the process-list and
microphone checks still work).

## Verify

After install, lock the screen (Ctrl+Cmd+Q) and tail the log:
Expand Down Expand Up @@ -135,7 +169,8 @@ bin/
├── uninstall # reverse install, preserve log
├── list-clients # parse synergy.conf → <short>.local hostnames
├── lock-watcher # subscribe to Darwin screen-lock notification
└── lock-fanout # ssh pmset displaysleepnow to each client
├── lock-fanout # ssh lock-guard to each client
└── lock-guard # client-side lock suppression (meeting detection)

tests/ # bats test suite (`bats tests/` to run)
docs/plans/ # per-slice design docs
Expand Down
2 changes: 1 addition & 1 deletion bin/install
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ say() { printf 'install: %s\n' "$*"; }

mkdir -p "$bin_dir" "$plist_dir" "$(dirname "$log_path")"

for b in lock-watcher lock-fanout list-clients; do
for b in lock-watcher lock-fanout list-clients lock-guard; do
ln -sf "$repo_root/bin/$b" "$bin_dir/$b"
say "symlinked $bin_dir/$b -> $repo_root/bin/$b"
done
Expand Down
6 changes: 5 additions & 1 deletion bin/lock-fanout
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ fanout_host() {
# reads from the caller's stdin, and in `main`'s `while read <<< "$clients"`
# loop that means ssh consumes the remaining hosts, so only the first
# iteration runs.
# Absolute path, with $HOME escaped so it expands on the remote shell, not
# locally: a non-interactive ssh session's PATH (/usr/bin:/bin:/usr/sbin:
# /sbin) does not include ~/.local/bin, where bin/install symlinks
# lock-guard.
"$ssh_cmd" -n -o BatchMode=yes \
-o ConnectTimeout=5 \
-o StrictHostKeyChecking=accept-new \
"$user@$host" pmset displaysleepnow >/dev/null 2>&1 || rc=$?
"$user@$host" "\$HOME/.local/bin/lock-guard" >/dev/null 2>&1 || rc=$?
log "client=$host user=$user ssh_exit=$rc"
}

Expand Down
95 changes: 95 additions & 0 deletions bin/lock-guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail

conf="${LOCK_SYNC_GUARD_PROCESSES:-$HOME/.config/lock-sync/guard-processes}"
pgrep_cmd="${LOCK_SYNC_PGREP:-/usr/bin/pgrep}"
osascript_cmd="${LOCK_SYNC_OSASCRIPT:-/usr/bin/osascript}"
pmset_cmd="${LOCK_SYNC_PMSET:-/usr/bin/pmset}"

default_processes=("zoom.us" "Microsoft Teams" "FaceTime")

log() {
local ts
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
printf '%s %s\n' "$ts" "$*"
}

guard_processes() {
if [[ -r "$conf" ]]; then
awk '/^[[:space:]]*(#|$)/ { next } { gsub(/[[:space:]]+$|\r$/, ""); if ($0 != "") print }' "$conf"
else
printf '%s\n' "${default_processes[@]}"
fi
}

# Prints the matching process name on stdout and returns 0 if any known
# meeting process is running; returns 1 (no output) otherwise. A pgrep
# failure for an individual name is treated as "not running", never fatal.
check_process() {
local name procs
# Captured to a variable (not process-substituted) because this function's
# own stdout is captured by the caller via $(check_process).
procs=$(guard_processes)
while IFS= read -r name; do
[[ -n "$name" ]] || continue
if "$pgrep_cmd" -xq "$name" 2>/dev/null; then
printf '%s\n' "$name"
return 0
fi
done <<<"$procs"
return 1
}

# Returns 0 if coreaudiod currently holds a PreventUserIdleSystemSleep power
# assertion, which it takes for the exact duration any process has an active
# audio input stream open. Verified against real mic open/close on Apple
# Silicon; IOAudioEngine (the previous approach) is a legacy Intel/kext-era
# IOKit class with zero live instances under the modern Core Audio HAL.
# pmset failure/unexpected output is treated as "not active", never fatal.
check_mic_active() {
"$pmset_cmd" -g assertions 2>/dev/null \
| grep -qi 'coreaudiod.*preventuseridlesleep'
}

# Returns 0 if any open Chrome tab's URL matches an active Meet room
# (meet.google.com/xxx-yyyy-zzz), as opposed to the bare landing page.
# osascript failure (e.g. Automation permission not yet granted, or Chrome
# not running) is treated as "no match", never fatal — see design doc's
# "macOS Automation permission" section.
check_meet_tab_open() {
local urls
urls="$("$osascript_cmd" -e '
with timeout of 3 seconds
tell application "Google Chrome"
set theURLs to {}
repeat with w in windows
repeat with t in tabs of w
set end of theURLs to URL of t
end repeat
end repeat
return theURLs
end tell
end timeout
' 2>/dev/null)" || return 1
[[ "$urls" =~ https://meet\.google\.com/[a-zA-Z0-9]{3}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{3} ]]
}

main() {
local match
if match="$(check_process)"; then
log "action=suppress reason=process:$match"
return 0
fi
if check_mic_active; then
log "action=suppress reason=mic-active"
return 0
fi
if check_meet_tab_open; then
log "action=suppress reason=meet-tab-open"
return 0
fi
"$pmset_cmd" displaysleepnow >/dev/null 2>&1 || true
log "action=sleep"
}

main "$@"
2 changes: 1 addition & 1 deletion bin/uninstall
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ if [[ -f "$plist_path" ]]; then
say "removed $plist_path"
fi

for b in lock-watcher lock-fanout list-clients; do
for b in lock-watcher lock-fanout list-clients lock-guard; do
if [[ -L "$bin_dir/$b" ]]; then
rm "$bin_dir/$b"
say "removed $bin_dir/$b"
Expand Down
Loading
Loading