expo-keep-awake is backed by systemd-logind's
org.freedesktop.login1.Manager.Inhibit("idle", …, "block")
on the system bus. Holding the fd that logind returns keeps the
inhibit alive; closing it releases.
The session-bus org.freedesktop.ScreenSaver interface only runs
inside a desktop session — gnome-screensaver, KDE's ksmserver,
mate-screensaver, xscreensaver, etc. logind is system-level and
ships on every systemd install (Ubuntu, Debian, Fedora, Arch,
Pop!_OS, openSUSE, …), so the inhibit works from headless
sessions, dev VMs, and CI runners without needing a screen-saver
daemon. The contract is the same as expo-keep-awake's: prevent
unattended idle / sleep until released.
JS app
↓ require('expo-keep-awake') ← metro/esbuild rewrite, linux only
@lucid-softworks/react-native-linux-expo/expo-keep-awake.js
├─ activateKeepAwakeAsync(tag) → rnLinux.keepAwakeActivate
├─ deactivateKeepAwake(tag) → rnLinux.keepAwakeDeactivate
├─ isAvailableAsync() → rnLinux.keepAwakeIsAvailable
└─ useKeepAwake(tag) hook → activate on mount, deactivate on unmount
↓
vnext/src/jsi/RnLinuxBindings.cpp ← JSI bindings
↓
vnext/src/keepawake/KeepAwake.cpp ← logind wrapper
↓
g_dbus_connection_call_with_unix_fd_list_sync
→ org.freedesktop.login1.Manager.Inhibit
→ returned fd held in tag→fd map
→ close(fd) releases the inhibit
Tag-keyed so multiple overlapping inhibitors (one per active route, component, etc.) can release independently. Bundle reload (Fast Refresh) walks the map and closes every held fd so a JS component that vanished mid-edit doesn't leak an inhibit until process exit.
Nothing. systemd-logind ships with systemd. Lima Ubuntu has it running on the system bus out of the box.
cd apps/playground
RN_ENTRY=smoke-demo.tsx node bundle.mjs
scripts/vm/sh.sh 'scripts/vm/run-playground.sh'Click keep awake in the expo-keep-awake section. While inhibiting, you can verify on the VM host:
systemd-inhibit --list
# WHO=react-native-linux WHAT=idle WHY="smoke demo" MODE=blockClick release to drop the inhibit.
| API | Behavior on Linux |
|---|---|
ExpoKeepAwakeTag |
String constant — default tag name |
isAvailableAsync() |
Real — NameHasOwner('org.freedesktop.login1') |
activateKeepAwakeAsync(tag, {reason}) |
Real — Manager.Inhibit("idle", "react-native-linux", reason, "block") |
activateKeepAwake(tag) |
Sync variant — same call without await |
deactivateKeepAwake(tag) |
Real — close(fd) for the tag |
useKeepAwake(tag, {reason}) |
React hook — activates on mount, deactivates on unmount |
- Sleep inhibit needs polkit. We ask only for
"idle"— enough to keep the display from blanking and the screen-saver from engaging. logind requires polkit'sorg.freedesktop.login1.inhibit-block-sleeppolicy for non-root"sleep"inhibits, which unprivileged user processes don't have by default. Adding a polkit rules drop-in (/etc/polkit-1/rules.d/) would letactivateKeepAwakeAsyncprevent system suspend too; for now we only block the display-blank / lock-screen path that expo-keep-awake's contract actually maps to. - Delay vs block mode — DONE.
activateKeepAwakeAsync/activateKeepAwakeaccept anoptions.modeof"block"(the default, hard inhibit) or"delay"(soft — system can proceed after a short timeout but the app gets a chance to react first). Anything else clamps back to"block"since logind rejects unknown modes. - Inhibitor naming — DONE.
options.whois threaded through to the logindwhoarg so each app surfaces a meaningful name insystemd-inhibit --list. Falls back to"react-native-linux"when blank. - Non-systemd Linux fallback — DONE. When the logind
Inhibitcall fails (no daemon ownsorg.freedesktop.login1, or it returned an error),activate()falls back toorg.freedesktop.ScreenSaver.Inhibit(application, reason)on the session bus — the interface every major desktop session (gnome-session, ksmserver, mate-session, xfce4-screensaver, cinnamon-screensaver) implements. The returned uint32 cookie is stored alongside any logind fd handles in the same tag map;deactivate()picks the right release path (close(fd)vsUnInhibit(cookie)) per handle.isAvailableAsyncnow returnstrueif either backend is reachable. - No portal path. Sandboxed apps (Flatpak, Snap) need the
org.freedesktop.portal.Inhibitinterface instead. We can add a portal fallback when running underFLATPAK_ID.