No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Erick Ruiz de Chavez cdd9937fba Prepare camera-watcher for publishing
Add an MIT license and the license and repository fields in
Cargo.toml, matching audio-watcher. Add the same release profile.

The README said camera-watcher reports a callback that exits without
reading its input. The status is small enough to fit in the pipe
buffer, so the write succeeds and nothing is reported. Say instead
that a callback does not have to read its input.

Assisted-By: Claude <noreply@anthropic.com>
2026-10-19 05:58:12 -04:00
src Initial import of camera-watcher 2026-10-19 09:41:31 +00:00
tests Initial import of camera-watcher 2026-10-19 09:41:31 +00:00
.gitignore Initial import of camera-watcher 2026-10-19 09:41:31 +00:00
Cargo.lock Initial import of camera-watcher 2026-10-19 09:41:31 +00:00
Cargo.toml Prepare camera-watcher for publishing 2026-10-19 05:58:12 -04:00
LICENSE Prepare camera-watcher for publishing 2026-10-19 05:58:12 -04:00
README.md Prepare camera-watcher for publishing 2026-10-19 05:58:12 -04:00

camera-watcher

camera-watcher is a small, event-driven macOS CLI that reports whether any camera on the Mac is in use. It listens to Core Media I/O's per-camera "running somewhere" notification, so it does not poll, and it sees every app that uses a camera, not just its own.

It can also run a callback program each time a camera starts or stops, for example to turn on a busy light or a key light. camera-watcher only tells the callback the status; what the callback does with it is up to you. Running as a LaunchAgent shows a complete example.

Requirements

Build and install

From the project directory:

cargo install --path . --locked

That builds a release binary and installs it at ~/.cargo/bin/camera-watcher. Run the same command again to install a new version.

Commands

Print the current status once:

camera-watcher status

Print the status as newline-delimited JSON immediately and each time a camera starts or stops:

camera-watcher watch

Run a command for the initial status and every change:

camera-watcher watch --exec /path/to/on-camera-change --an-option value

Everything after --exec is the callback and its arguments. camera-watcher passes them on unchanged, without a shell, so --exec must come last.

In --exec mode, each callback receives one JSON object followed by a newline on standard input. Callbacks run one at a time, and camera-watcher does not look at the cameras again until the current callback exits. The callback shares camera-watcher's standard output and error. If a callback exits with a non-zero status, camera-watcher reports it on standard error and keeps watching. A callback does not have to read its input. If the callback cannot start, camera-watcher exits.

--exec replaces the watch stream rather than duplicating it on camera-watcher's own standard output.

JSON format

{"in_use":true,"cameras":[{"uid":"0x8020000005ac8514","name":"FaceTime HD Camera","in_use":true}]}
  • in_use is true when at least one camera is in use. Most callbacks only need this.
  • cameras lists every camera Core Media I/O reports, in use or not, sorted by UID.
  • uid is the camera's persistent Core Media I/O UID, the same as AVFoundation's uniqueID.
  • name is the camera's display name.
  • Each camera's in_use says whether any process is using it.

Change detection

watch always delivers the initial status. After that, it delivers a status only when the set of cameras in use differs from the last one delivered:

  • A camera starting or stopping is a change. So is a second camera starting while the first is still on.
  • A camera being connected or disconnected while idle is not, so an iPhone coming in and out of Continuity Camera range does not run the callback. Disconnecting a camera that is in use is a change, because it stops being in use.
  • After a notification, watch waits until 500 ms pass with no further notifications, then reads the cameras. That treats a burst of notifications as one change, and it keeps a light from flickering when an app releases the camera and grabs it again (switching cameras, or a call moving from preview to the meeting).

If a camera cannot be read, watch retries after 250 ms, 500 ms and 1 s, then leaves it out and prints a message on standard error. If the camera list itself cannot be read, watch reports it and tries again at the next change. At startup, it exits instead.

What counts as "in use" is up to macOS: any process with the camera open, including browsers, Photo Booth and virtual camera apps. When an app such as OBS feeds a virtual camera from a real one, both show as in use while something is consuming the virtual camera.

Running as a LaunchAgent

A per-user LaunchAgent starts camera-watcher at login and restarts it if it exits.

1. Install the binary

Install camera-watcher as described in Build and install, then confirm where it is:

command -v camera-watcher

2. Create the callback script

The LaunchAgent runs ~/.local/bin/on-camera-change for each status. LaunchAgents run with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), so the examples call every program by absolute path. They read the JSON with jq, which macOS 15 Sequoia and later include at /usr/bin/jq.

Simple example: log and switch a light

Replace the commented lines with whatever turns your light on and off.

mkdir -p ~/.local/bin
cat > ~/.local/bin/on-camera-change <<'EOF'
#!/bin/sh
# camera-watcher runs this with one JSON status on standard input.

if [ "$(/usr/bin/jq -r .in_use)" = true ]; then
  /bin/date '+%Y-%m-%d %H:%M:%S camera on'
  # /path/to/busylight on
else
  /bin/date '+%Y-%m-%d %H:%M:%S camera off'
  # /path/to/busylight off
fi
EOF
chmod 755 ~/.local/bin/on-camera-change

Keyboard Maestro example: run a macro

camera-watcher does not need Keyboard Maestro. This script passes on or off to a macro as its parameter. Replace YOUR-MACRO-UUID with the macro's UUID (in the Keyboard Maestro Editor, select the macro and choose Edit, then Copy as, then Copy UUID).

mkdir -p ~/.local/bin
cat > ~/.local/bin/on-camera-change <<'EOF'
#!/bin/sh
# camera-watcher runs this with one JSON status on standard input. It passes
# "on" or "off" to a Keyboard Maestro macro.

state=$(/usr/bin/jq -r 'if .in_use then "on" else "off" end') || exit

# Keyboard Maestro's "do script" returns "missing value", and osascript would
# print it. Discard standard output only, so errors stay visible.
/usr/bin/osascript \
  -e 'on run argv' \
  -e 'tell application "Keyboard Maestro Engine" to do script "YOUR-MACRO-UUID" with parameter (item 1 of argv)' \
  -e 'end run' \
  "$state" >/dev/null
EOF
chmod 755 ~/.local/bin/on-camera-change

In the macro, the parameter is available as %TriggerValue%.

Try the script

camera-watcher status | ~/.local/bin/on-camera-change

3. Create the LaunchAgent

launchd does not expand ~ or $HOME, so the plist needs absolute paths. This command writes it with your paths filled in:

watcher=$(command -v camera-watcher)
mkdir -p ~/Library/LaunchAgents ~/Library/Logs
cat > ~/Library/LaunchAgents/sh.erick.code.camera-watcher.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>sh.erick.code.camera-watcher</string>
    <key>ProgramArguments</key>
    <array>
        <string>$watcher</string>
        <string>watch</string>
        <string>--exec</string>
        <string>$HOME/.local/bin/on-camera-change</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>ProcessType</key>
    <string>Background</string>
    <key>StandardOutPath</key>
    <string>$HOME/Library/Logs/camera-watcher.log</string>
    <key>StandardErrorPath</key>
    <string>$HOME/Library/Logs/camera-watcher.log</string>
</dict>
</plist>
EOF
plutil -lint ~/Library/LaunchAgents/sh.erick.code.camera-watcher.plist

4. Load it

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/sh.erick.code.camera-watcher.plist

The callback runs once immediately with the current status, then on every change.

Managing the agent

# Is it running? (look for state = running)
launchctl print gui/$(id -u)/sh.erick.code.camera-watcher | grep state

# Messages and callback output
tail -f ~/Library/Logs/camera-watcher.log

# Restart after installing a new binary (script changes need nothing)
launchctl kickstart -k gui/$(id -u)/sh.erick.code.camera-watcher

# Stop until next login
launchctl bootout gui/$(id -u)/sh.erick.code.camera-watcher

If the callback script is missing or not executable, camera-watcher exits and launchd restarts it about every 10 seconds. The log shows could not start callback.

Uninstall

launchctl bootout gui/$(id -u)/sh.erick.code.camera-watcher
rm ~/Library/LaunchAgents/sh.erick.code.camera-watcher.plist
rm ~/.local/bin/on-camera-change
rm ~/Library/Logs/camera-watcher.log
cargo uninstall camera-watcher

Development checks

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --locked

The watch loop, JSON and callback handling are tested without a camera. The tests in tests/ run the real binary against Core Media I/O on macOS: they check the status output and that watch --exec delivers the initial status. Starting and stopping a camera still has to be tested by hand (open Photo Booth, then quit it).

License

MIT. See LICENSE.