A lightweight Rust daemon that watches Upwork's time-tracking app and detects when it captures a screenshot, in real time, exposing the events over a local WebSocket API. On macOS it ships with Worklog, a small native control panel.
| Binary | What it is |
|---|---|
time-whisperer |
The headless daemon: watches Upwork logs, runs the localhost WebSocket server, exposes /health. Runs as a background service. |
worklog-gui |
macOS control-panel app ("Worklog"): turn monitoring on/off, see live status and whether a client is connected. Target-gated to macOS. |
- Real-time screenshot detection — watches Upwork log files for the "Electron Screensnap succeeded" signature
- WebSocket API — authenticated, token-based handshake; localhost only
- Single-instance guard — an advisory file lock means only one daemon ever runs; a second invocation exits cleanly
- Health endpoint —
GET /healthreports status, version, build commit, and the number of connected clients - Signed & notarized macOS installer — Developer ID signed
.pkg/.dmg, notarized by Apple (installs with no Gatekeeper warning) - Client-agnostic — any client that completes the handshake counts; the daemon and GUI don't assume a specific client
The daemon tails the Upwork application logs and watches for entries containing
"Electron Screensnap succeeded", which mark a captured screenshot. Each event is
broadcast as a screenshot_detected message to authenticated WebSocket clients.
Download the latest Worklog-<version>-macos-arm64.pkg from the
Releases page and
double-click it. The installer wizard puts the app in /Applications and starts
the background monitor as a per-user LaunchAgent (starts at login, no Dock icon,
never appears in a screenshot). The .pkg is Developer ID signed and notarized,
so it installs without Gatekeeper warnings.
A drag-to-Applications .dmg is also published as an alternative.
Download time-whisperer-linux-amd64 from Releases:
chmod +x time-whisperer-linux-amd64
./time-whisperer-linux-amd64(Wire it into a systemd user service yourself if you want it to start at login.)
Requires a stable Rust toolchain (cargo).
git clone https://github.com/Hyperbach/time-whisperer.git
cd time-whisperer
cargo build --release
# daemon: target/release/time-whisperer
# control panel (macOS): target/release/worklog-gui# Run the daemon in the foreground
./time-whisperer
# Print version (and build commit / date)
./time-whisperer --versionOn macOS the daemon can manage its own LaunchAgent:
time-whisperer install # install the LaunchAgent and start it now
time-whisperer status # show whether it's installed / running
time-whisperer uninstall # stop and remove the LaunchAgentMost users never touch the CLI — the Worklog app does install/start/stop and shows live status (monitoring active, client connected, last screenshot seen).
The daemon will:
- Acquire the single-instance lock (a second daemon exits immediately)
- Load configuration from the platform-specific location
- Bind the WebSocket server to the first free candidate port (starting at 8887)
- Watch the Upwork log directory for
upwork.*.logfiles - Broadcast
screenshot_detectedto authenticated clients
The daemon exposes a WebSocket endpoint at /ws:
Authentication flow
- Client connects
- Server sends
hellowith an authentication token - Client replies
hello_ackechoing the token - Server confirms with
connected
Message types
screenshot_detected— a new screenshot was detectedping/pong— heartbeathello/hello_ack/connected— handshake
Health endpoint
GET /health→ JSON:status,version,commit,clients(connected client count),timestamp
UPWORK_LOGS_DIR— override the Upwork log directoryWORKLOG_DAEMON— (used by the GUI) path to the daemon binary to manage
Config file location:
- macOS:
~/Library/Application Support/TimeWhisperer/config.json - Linux:
~/.config/time-whisperer/config.json - Windows:
%APPDATA%\Local\TimeWhisperer\config.json - Development:
config.jsonin the current directory
{
"debugMode": false,
"logPath": "~/Library/Logs/TimeWhisperer/time-whisperer.log",
"upworkLogsDir": "~/Library/Application Support/Upwork/Upwork/Logs",
"webSocketPort": 8887
}debugMode— enable debug logging and the/test/broadcastendpointlogPath— application log file (supports~)upworkLogsDir— directory containing Upwork logs (auto-discovered on first run if empty)webSocketPort— preferred port (falls back to the candidate list)
- macOS:
~/Library/Application Support/Upwork/Upwork/Logs - Linux:
~/.config/Upwork/Logs - Windows:
~/AppData/Roaming/Upwork/Logs
The daemon binds the first free port from a deterministic candidate list,
starting with 8887 (49205, 49231, … and so on). Clients probe the same list to
find the running daemon. The list is shared between the daemon and clients so
connectivity is reliable across restarts.
The WebSocket server:
- binds only to localhost (
127.0.0.1) - requires token-based authentication
- is intended for local communication and must not be exposed to external networks
- macOS (arm64) — app + daemon; built, signed, and notarized in CI
- Linux (amd64) — daemon binary; built in CI
- Windows — the source has Windows path handling, but CI does not currently build or package a Windows release
cargo build --release # build
cargo test # unit + integration tests
./run_integration_tests.sh # integration tests serially (port-bound)Releases are produced by .github/workflows/release.yml — a cargo-native
pipeline (no Nix). Pushing a v* tag builds, signs, notarizes, and publishes a
GitHub Release. See VERIFICATION.md to verify a download.
MIT