Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions lid-guard/README.md
Original file line number Diff line number Diff line change
@@ -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
```
22 changes: 22 additions & 0 deletions lid-guard/plugin.toml
Original file line number Diff line number Diff line change
@@ -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"
127 changes: 127 additions & 0 deletions lid-guard/service.luau
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lid-guard/shortcut.luau
Original file line number Diff line number Diff line change
@@ -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 added lid-guard/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions lid-guard/translations/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
59 changes: 59 additions & 0 deletions lid-guard/widget.luau
Original file line number Diff line number Diff line change
@@ -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