- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| README.md | ||
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
- macOS
- A current stable Rust toolchain (install with rustup)
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_useistruewhen at least one camera is in use. Most callbacks only need this.cameraslists every camera Core Media I/O reports, in use or not, sorted by UID.uidis the camera's persistent Core Media I/O UID, the same as AVFoundation'suniqueID.nameis the camera's display name.- Each camera's
in_usesays 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,
watchwaits 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.