diff --git a/lid-guard/README.md b/lid-guard/README.md new file mode 100644 index 0000000..8abf966 --- /dev/null +++ b/lid-guard/README.md @@ -0,0 +1,60 @@ +# Lid Guard + +Lid Guard keeps a laptop awake when its lid is closed, allowing AI agents, +builds, downloads, and other background jobs to continue without changing the +system-wide lid policy. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `8bury/lid-guard` | +| Entries | Bar widget: `lid-guard`; shortcut: `lid-guard-toggle`; service: `lid-guard-service` | + +## Requirements + +Install `systemctl`, `systemd-run`, and `systemd-inhibit` from systemd, plus +`sleep` from coreutils, on `PATH`. The active user session must use +`systemd-logind` and have a working user service manager. + +## Usage + +Add the `lid-guard` widget to a bar. Left-click the shield to toggle the mode; +right-click it to refresh the detected state. A highlighted shield means the +lid-close inhibitor is active. + +To use the same toggle from the Control Center, add the `lid-guard-toggle` +shortcut in Settings → Control Center → Shortcuts. + +The service entry `lid-guard-service` owns the inhibitor and can also be +controlled through IPC: + +```sh +noctalia msg plugin 8bury/lid-guard:lid-guard-service all enable +noctalia msg plugin 8bury/lid-guard:lid-guard-service all disable +noctalia msg plugin 8bury/lid-guard:lid-guard-service all toggle +noctalia msg plugin 8bury/lid-guard:lid-guard-service all refresh +``` + +## IPC + +The `enable`, `disable`, and `toggle` events change the inhibitor state. +`refresh` and `status` re-check the transient user service without changing it. +IPC events take no payload. + +## Notes + +Lid Guard makes no network requests and writes no files. It spawns +`systemctl`, `systemd-run`, `systemd-inhibit`, and `sleep` as the current user. +When enabled, it creates the transient user service +`noctalia-lid-guard.service`; disabling the mode stops that service. + +The inhibitor covers only `handle-lid-switch`. It does not prevent suspension +requested by an idle daemon, a power menu, or another explicit command, and it +does not modify `logind.conf`. The mode ends when the user session or user +service manager stops. If Noctalia is unavailable, restore the normal lid +behavior with: + +```sh +systemctl --user stop noctalia-lid-guard.service +``` diff --git a/lid-guard/plugin.toml b/lid-guard/plugin.toml new file mode 100644 index 0000000..d054229 --- /dev/null +++ b/lid-guard/plugin.toml @@ -0,0 +1,22 @@ +id = "8bury/lid-guard" +name = "Lid Guard" +version = "1.0.0" +plugin_api = 3 +author = "8bury" +license = "MIT" +icon = "shield-lock" +description = "Keep the laptop awake when its lid is closed, so background jobs can continue running." +tags = ["bar", "indicator", "productivity", "service", "shortcut", "system", "utility"] +dependencies = ["systemctl", "systemd-run", "systemd-inhibit", "sleep"] + +[[service]] +id = "lid-guard-service" +entry = "service.luau" + +[[widget]] +id = "lid-guard" +entry = "widget.luau" + +[[shortcut]] +id = "lid-guard-toggle" +entry = "shortcut.luau" diff --git a/lid-guard/service.luau b/lid-guard/service.luau new file mode 100644 index 0000000..b0618be --- /dev/null +++ b/lid-guard/service.luau @@ -0,0 +1,127 @@ +--!nonstrict +-- Owns the systemd inhibitor. Widgets and shortcuts communicate with this +-- singleton through noctalia.state. + +local UNIT = "noctalia-lid-guard.service" +local CHECK_COMMAND = `systemctl --user is-active --quiet {UNIT}` +local START_COMMAND = `systemctl --user reset-failed {UNIT} >/dev/null 2>&1 || true; ` .. table.concat({ + `systemd-run --user --quiet --collect --unit={UNIT}`, + `--description='Noctalia Lid Guard'`, + `systemd-inhibit --what=handle-lid-switch`, + `--who='Noctalia Lid Guard'`, + `--why='Keep background jobs running while the laptop lid is closed'`, + `--mode=block sleep infinity`, +}, " ") +local STOP_COMMAND = `systemctl --user stop {UNIT}` + +local active = false +local busy = false +local available = noctalia.commandExists("systemctl") + and noctalia.commandExists("systemd-run") + and noctalia.commandExists("systemd-inhibit") + and noctalia.commandExists("sleep") +local revision = 0 + +local function publish(errorMessage) + revision += 1 + noctalia.state.set("lid_guard_status", { + active = active, + busy = busy, + available = available, + error = errorMessage or "", + revision = revision, + }) +end + +local function refresh(callback) + if not available then + active = false + publish(noctalia.tr("error.missing_dependencies")) + if callback then callback(false) end + return + end + + noctalia.runAsync(CHECK_COMMAND, function(result) + active = result.exitCode == 0 + publish() + if callback then callback(active) end + end) +end + +local function finishToggle(expectedActive, result) + if result.exitCode ~= 0 then + busy = false + local detail = noctalia.string.trim(result.stderr or "") + if detail == "" then detail = noctalia.tr("error.command_failed") end + publish(detail) + noctalia.notifyError(noctalia.tr("title"), detail) + return + end + + refresh(function(currentActive) + busy = false + active = currentActive + publish() + + if currentActive ~= expectedActive then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("error.state_mismatch")) + elseif currentActive then + noctalia.notify(noctalia.tr("notification.enabled.title"), noctalia.tr("notification.enabled.body")) + else + noctalia.notify(noctalia.tr("notification.disabled.title"), noctalia.tr("notification.disabled.body")) + end + end) +end + +local function setActive(nextActive) + if busy or not available then return end + busy = true + publish() + + if nextActive then + noctalia.runAsync(START_COMMAND, function(result) + finishToggle(true, result) + end) + else + noctalia.runAsync(STOP_COMMAND, function(result) + finishToggle(false, result) + end) + end +end + +noctalia.state.watch("lid_guard_command", function(command) + if type(command) ~= "table" then return end + + if command.action == "toggle" then + refresh(function(currentActive) + setActive(not currentActive) + end) + elseif command.action == "enable" then + setActive(true) + elseif command.action == "disable" then + setActive(false) + elseif command.action == "refresh" then + refresh() + end +end) + +noctalia.setUpdateInterval(5000) +refresh() + +function update() + if not busy then refresh() end +end + +function onIpc(event, _payload) + if event == "toggle" then + refresh(function(currentActive) + setActive(not currentActive) + end) + elseif event == "enable" then + setActive(true) + elseif event == "disable" then + setActive(false) + elseif event == "refresh" or event == "status" then + refresh() + end +end diff --git a/lid-guard/shortcut.luau b/lid-guard/shortcut.luau new file mode 100644 index 0000000..db2c4ea --- /dev/null +++ b/lid-guard/shortcut.luau @@ -0,0 +1,34 @@ +--!nonstrict + +local status = noctalia.state.get("lid_guard_status") or { + active = false, + busy = false, + available = true, +} +local requestId = 0 + +local function render() + local active = status.active == true + shortcut.setLabel(if active then noctalia.tr("shortcut.active") else noctalia.tr("shortcut.inactive")) + shortcut.setIcon("shield-lock", "shield-off") + shortcut.setActive(active) + shortcut.setEnabled(status.available ~= false and status.busy ~= true) +end + +noctalia.state.watch("lid_guard_status", function(value) + if type(value) == "table" then + status = value + render() + end +end) + +render() + +function onClick() + if status.available == false or status.busy == true then return end + requestId += 1 + noctalia.state.set("lid_guard_command", { + action = "toggle", + requestId = `shortcut-{requestId}`, + }) +end diff --git a/lid-guard/thumbnail.webp b/lid-guard/thumbnail.webp new file mode 100644 index 0000000..71c082f Binary files /dev/null and b/lid-guard/thumbnail.webp differ diff --git a/lid-guard/translations/en.json b/lid-guard/translations/en.json new file mode 100644 index 0000000..578d709 --- /dev/null +++ b/lid-guard/translations/en.json @@ -0,0 +1,28 @@ +{ + "title": "Lid Guard", + "tooltip": { + "active": "Lid Guard is on — closing the lid will not suspend the laptop", + "inactive": "Lid Guard is off — click to keep the laptop awake with the lid closed", + "changing": "Changing Lid Guard state…", + "unavailable": "Lid Guard requires systemd" + }, + "shortcut": { + "active": "Lid Guard On", + "inactive": "Lid Guard Off" + }, + "notification": { + "enabled": { + "title": "Lid Guard enabled", + "body": "Closing the laptop lid will no longer suspend it." + }, + "disabled": { + "title": "Lid Guard disabled", + "body": "The normal lid-close behavior has been restored." + } + }, + "error": { + "missing_dependencies": "systemctl, systemd-run, systemd-inhibit, and sleep are required.", + "command_failed": "The systemd command failed.", + "state_mismatch": "The requested state could not be confirmed." + } +} diff --git a/lid-guard/widget.luau b/lid-guard/widget.luau new file mode 100644 index 0000000..82dd2bb --- /dev/null +++ b/lid-guard/widget.luau @@ -0,0 +1,59 @@ +--!nonstrict + +local status = noctalia.state.get("lid_guard_status") or { + active = false, + busy = false, + available = true, +} +local requestId = 0 + +local function render() + if status.available == false then + barWidget.setGlyph("shield-x") + barWidget.setGlyphColor("error") + barWidget.setTooltip(noctalia.tr("tooltip.unavailable")) + elseif status.busy == true then + barWidget.setGlyph("loader-2") + barWidget.setGlyphColor("primary") + barWidget.setTooltip(noctalia.tr("tooltip.changing")) + elseif status.active == true then + barWidget.setGlyph("shield-lock") + barWidget.setGlyphColor("tertiary") + barWidget.setTooltip(noctalia.tr("tooltip.active")) + else + barWidget.setGlyph("shield-off") + barWidget.setGlyphColor("on_surface_variant") + barWidget.setTooltip(noctalia.tr("tooltip.inactive")) + end +end + +noctalia.state.watch("lid_guard_status", function(value) + if type(value) == "table" then + status = value + render() + end +end) + +render() + +function onClick() + if status.available == false then + noctalia.notifyError(noctalia.tr("title"), status.error or noctalia.tr("error.missing_dependencies")) + return + end + if status.busy == true then return end + + requestId += 1 + noctalia.state.set("lid_guard_command", { + action = "toggle", + requestId = `widget-{requestId}`, + }) +end + +function onRightClick() + requestId += 1 + noctalia.state.set("lid_guard_command", { + action = "refresh", + requestId = `widget-refresh-{requestId}`, + }) +end