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