feat(lid-guard): add lid-close suspension inhibitor (#30)
* feat(lid-guard): add lid-close suspension inhibitor * docs(lid-guard): replace plugin thumbnail
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user