Locks your screen when your phone walks away.
LockiDucky pings a Bluetooth device you carry with L2CAP echo requests. While it answers, nothing happens. When it stops answering for long enough, the screen locks. Walking back cancels a pending lock.
Built for Omarchy 4 (Hyprland + Quickshell), with a logind fallback for everything else.
$ lockiducky status
Device: iPhone (00:00:5E:00:53:01)
Presence: present (18ms)
Screen: unlocked
The obvious design is to watch RSSI and lock when the signal gets weak. That does
not work with an iPhone on Linux: it rotates the address it advertises over BLE
and reports no usable signal strength to a bluetoothctl scan, so the reading is
either missing or attached to an address that will not be there next time.
An L2CAP echo request — the thing l2ping sends — is a yes/no question sent to
the stable classic-Bluetooth address you get from pairing, and an iPhone answers
it in 10–40 ms. Presence is a sturdier signal than distance, and the whole design
follows from that: LockiDucky counts consecutive unanswered pings, not decibels.
- Linux with a Bluetooth adapter
- A device paired to this machine that speaks classic Bluetooth (BR/EDR) — phones and headphones do. L2CAP echo cannot reach BLE-only hardware, so most fitness bands and BLE-only tags will never answer and are not usable here.
CAP_NET_RAW, because L2CAP echo needs a raw socket (make installsets it)- Go 1.25+ to build from source
- Omarchy 4 for the bar widget and the tested lock path; elsewhere LockiDucky
falls back to
loginctl
git clone https://github.com/magudb/lockiducky.git
cd lockiducky
make install # builds, installs to /usr/local/bin, grants CAP_NET_RAWOr grab a binary from Releases and grant the capability yourself:
sudo install -Dm755 lockiducky /usr/local/bin/lockiducky
sudo setcap cap_net_raw+ep /usr/local/bin/lockiducky1. Pair the device you want to be tracked by, and note its address:
bluetoothctl devices2. Write a config and point it at that address:
lockiducky init # writes ~/.config/lockiducky/config.yaml
$EDITOR ~/.config/lockiducky/config.yaml3. Check that it answers:
lockiducky ping -c 3Lock method: omarchy
00:00:5E:00:53:01: response in 18ms
00:00:5E:00:53:01: response in 22ms
00:00:5E:00:53:01: response in 15ms
If you get permission denied opening a raw Bluetooth socket, the binary is
missing CAP_NET_RAW — rerun make install.
4. Run it as a user service:
make service # installs and enables the unit
journalctl --user -u lockiducky -f # watch it workOmarchy 4's bar is Quickshell, and it reads a command module — a program it runs
on an interval whose JSON it renders. lockiducky status --bar prints exactly
that.
Add this entry to bar.layout.right in ~/.config/omarchy/shell.json:
{
"id": "lockiducky",
"type": "command",
"exec": "lockiducky status --bar",
"interval": 5,
"onClick": "lockiducky override"
}Two things about that file are worth knowing before you edit it:
- It replaces the defaults wholesale. Omarchy does not deep-merge, so a
hand-written
shell.jsonneeds the full bar layout, not just your addition. If you do not have one yet, start from$OMARCHY_PATH/config/omarchy/shell.json. - It must contain
"version": 1or Omarchy silently ignores it and uses the defaults.
Saving the file applies it immediately — the shell watches it, so there is nothing to restart.
The widget shows presence as an icon and everything else in the tooltip:
| Icon | Meaning |
|---|---|
| | Device nearby |
| | Device away — a lock is pending or has happened |
| | Automatic locking paused (click to resume) |
| | Daemon not running, or it has had no answer from the device yet |
Clicking pauses and resumes automatic locking, which is the escape hatch for the day you leave your phone on your desk at home.
The question-mark icon is worth paying attention to: until the device answers
once, the daemon will not lock anything, so a wrong address or a binary that lost
CAP_NET_RAW shows up in the bar instead of failing silently. The tooltip says
which.
Waybar (Omarchy 3 and other setups)
The JSON is Waybar's format, so a Waybar custom module works too. Define it, then
add "custom/lockiducky" to one of the modules-left / modules-center /
modules-right arrays — defining a module without listing it there is the usual
reason nothing shows up:
Waybar applies the class values as CSS classes, so you can colour the states:
#custom-lockiducky.present { color: #8bc34a; } /* device nearby */
#custom-lockiducky.away { color: #e06c75; } /* device gone */
#custom-lockiducky.paused { color: #e5c07b; } /* locking paused */
#custom-lockiducky.unseen { color: #e5c07b; } /* no answer yet */
#custom-lockiducky.stopped { color: #6a7a71; } /* daemon not running */Quickshell ignores those classes — it only reacts to active, which LockiDucky
emits alongside them for the states worth an accent — so one output serves both.
| Command | What it does |
|---|---|
lockiducky daemon |
Watch the device and lock when it leaves. --verbose logs every ping, --dry-run never actually locks |
lockiducky status |
What the daemon currently sees. --json for the raw state, --bar for one line of bar JSON |
lockiducky ping [mac] |
Send one echo request. The fastest way to check an address and the capability |
lockiducky override |
Pause or resume automatic locking. --on / --off to be explicit |
lockiducky lock |
Lock the screen now |
lockiducky init |
Write the annotated config file |
~/.config/lockiducky/config.yaml. LockiDucky only ever reads it, so your
comments survive.
device:
mac: "00:00:5E:00:53:01"
name: "iPhone"
ping:
interval: 5s # how often to ping
away_interval: 30s # slowest ping once away and already locked; 0 disables
timeout: 2s # per-ping timeout; must be less than interval
failure_threshold: 3 # consecutive misses before the device counts as away
lock_delay: 30s # grace period between "away" and locking
inhibit:
remote_session: true # never lock while a VNC client is attached
command: "" # also hold off whenever this exits 0
lock:
command: "" # empty autodetects: omarchy-system-lock, else loginctl
is_locked_command: "" # must print "true" or "yes" when lockedWith the defaults, that is three missed pings five seconds apart and then a 30 second grace period — roughly 40 seconds between walking out of range and the screen locking. A typo in a key name is an error rather than a silent default: the config is validated on load.
away_interval only affects what happens after the screen is locked, so it
never changes that timing — see Sharing the adapter.
The daemon is the only process that opens a Bluetooth socket. Every interval it
sends one echo request and writes what it saw to
$XDG_RUNTIME_DIR/lockiducky/state.json. lockiducky status and the bar widget
read that file, so polling the bar every few seconds costs nothing, needs no
capability, and never wakes your phone a second time.
Once failure_threshold pings in a row go unanswered, the daemon sets a lock
deadline lock_delay in the future. Any answered ping before then clears it. When
the deadline passes, it locks once and disarms until the device is seen again.
Override lives in ~/.local/state/lockiducky/override rather than in the config,
so toggling it from a bar click never rewrites your config file, and the daemon
picks it up on its next tick without a restart.
Pinging an absent device is not free to the rest of your Bluetooth. A classic Bluetooth page runs until the controller gives up — 5.12 seconds by default — and a controller that is paging is not scanning, so it cannot answer a headset or a keyboard trying to connect, or a phone trying to pair. Polling an absent device every 5 seconds therefore keeps the adapter busy more or less permanently, and the symptom shows up on other devices rather than on LockiDucky.
So once the device is away and the screen is already locked, the ping doubles
its wait — 5s, 10s, 20s, up to away_interval — and snaps back the moment the
device answers. Pings before a lock are never slowed: while a lock is pending,
the next answer still decides whether the screen locks, so it keeps the full
rate and lock timing is unchanged. Set away_interval: 0s to switch the
backoff off, at the cost of an adapter that stays busy while you are away.
Presence is the wrong signal when you are not in the room. Over VNC your phone is with you, several kilometres from the machine, so it never answers and LockiDucky would lock the session you are working in about forty seconds after you connect.
On Omarchy 4 that is not merely annoying. The lock plugin blanks the output a few seconds after any lock, and wayvnc pauses frame capture on a dark output with no resume path — so the client stays connected to a black screen for the rest of the session, and no amount of waking the display brings it back. Locking a live VNC session effectively ends it.
So inhibit.remote_session is on by default. Each tick, if wayvncctl is on
PATH, the daemon asks it whether any client is attached and holds off any lock
while one is. It suppresses locking, not pinging: the bar keeps showing where
your phone actually is, and lockiducky status says why nothing is happening.
$ lockiducky status
Device: iPhone (00:00:5E:00:53:01)
Presence: away (37 missed pings)
Screen: unlocked
Locking: held off - a VNC client is connected
A wayvnc that is down or unreachable counts as nobody watching, so a broken
detector fails towards locking rather than towards never locking again. When the
last client disconnects the hold-off clears, and if your phone is still away the
normal failure_threshold + lock_delay sequence runs and the screen locks —
which is what you want, because at that point the machine is unattended.
inhibit.command is the general form: any command that exits 0 holds off a lock.
It runs on every tick through sh -c, so keep it cheap.
inhibit:
command: "pgrep -x obs" # don't lock mid-recordingIt does not unlock. Omarchy 4's lock screen is a Quickshell
ext-session-lock surface gated on PAM, with no IPC to open it — and it does not
implement the logind lock protocol either, so loginctl unlock-session has
nothing to talk to. There is no honest way to unlock a locked Omarchy 4 session
from a Bluetooth ping, so LockiDucky does not pretend to. Coming back in time
cancels a pending lock; after that, you authenticate.
It does not keep your screen awake. Deciding when an idle screen sleeps is your idle daemon's job, and fighting it from here only produces surprises.
It is not a security boundary. Bluetooth presence is spoofable and a paired device that is merely nearby is enough. Treat it as a convenience that makes you lock more often than you otherwise would, not as authentication — see SECURITY.md.
permission denied opening a raw Bluetooth socket — the binary lost
CAP_NET_RAW. Any reinstall or upgrade drops it:
sudo setcap cap_net_raw+ep $(command -v lockiducky).
connect to …: connection refused or timeouts — the device is out of range,
its Bluetooth is off, or it was never paired. Pair and trust it in
bluetoothctl, then retry lockiducky ping.
Other devices cannot pair or connect while you are away — the adapter is
busy paging your absent phone. Check away_interval is set (a config written
before it existed picks up the 30s default automatically), and confirm the
backoff is running: the daemon logs backing the ping off to 30s ... at
startup.
Bar widget shows — either the daemon is not publishing, or it has not had
an answer from the device yet; the tooltip says which.
systemctl --user status lockiducky and journalctl --user -u lockiducky.
Those logs name your device's Bluetooth address, so redact it before pasting
them into an issue — it identifies hardware you carry around.
Nothing happens when the device leaves — check lockiducky status for an
override you forgot or a hold-off in force (Locking: held off - ...), then run
lockiducky daemon --verbose --dry-run in a terminal and watch the decisions. A
stale VNC client that never disconnected cleanly will hold a lock off
indefinitely; wayvncctl client-list shows what wayvnc still thinks is attached.
It locks while the phone is right there — an iPhone drops the Bluetooth link
when it has nothing to say. Raise failure_threshold, or lock_delay, or both.
Note that interrupted system call in the logs is not this: it was a bug in
LockiDucky's own socket handling, fixed by retrying interrupted syscalls rather
than counting them as a missed ping.
make check # formatting, vet, race tests, cross-arch build — what CI runs
make test # just the tests
make build # ./build/lockiduckyThe tree is small on purpose:
cmd/ one file per subcommand
internal/watch/ when to lock - the state machine, driven a tick at a time
internal/bluetooth/ the L2CAP echo socket
internal/config/ YAML config and its embedded template
internal/locker/ locking, as two shell commands
internal/inhibit/ reasons not to lock yet, such as an attached VNC client
internal/state/ what the daemon publishes, and the override flag
internal/bar/ the bar widget payload
See CONTRIBUTING.md.
MIT — see LICENSE.