diff --git a/clamshell/.gitignore b/clamshell/.gitignore new file mode 100644 index 000000000..e458ed577 --- /dev/null +++ b/clamshell/.gitignore @@ -0,0 +1 @@ +.worktrees/ diff --git a/clamshell/BarWidget.qml b/clamshell/BarWidget.qml new file mode 100644 index 000000000..1b04978d9 --- /dev/null +++ b/clamshell/BarWidget.qml @@ -0,0 +1,73 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +NIconButton { + id: root + + property ShellScreen screen + property var pluginApi: null + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property bool alwaysShowBarWidget: cfg.alwaysShowBarWidget !== undefined + ? cfg.alwaysShowBarWidget + : (defaults.alwaysShowBarWidget !== undefined ? defaults.alwaysShowBarWidget : false) + + readonly property var main: pluginApi ? pluginApi.mainInstance : null + readonly property bool isActive: !!main && main.enabled && main.externalPresent + readonly property bool isDisabled: !main || !main.enabled + readonly property string stateText: main?.stateLabel ? main.stateLabel() : pluginApi?.tr("state.disabled") + readonly property string outputsText: main?.outputSummary ? main.outputSummary() : "" + + visible: root.alwaysShowBarWidget || (!!main && main.inhibitorActive) + icon: "device-desktop" + tooltipText: outputsText ? stateText + "\n" + outputsText : stateText + tooltipDirection: BarService.getTooltipDirection(screen?.name) + baseSize: Style.getCapsuleHeightForScreen(screen?.name) + applyUiScale: false + customRadius: Style.radiusL + colorBg: Style.capsuleColor + colorFg: root.isActive ? Color.mPrimary : (root.isDisabled ? Color.mOnSurfaceVariant : Color.mSecondary) + + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + onClicked: { + if (root.main) { + root.main.toggle(); + } + } + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": pluginApi?.tr("menu.settings"), + "action": "settings", + "icon": "settings" + } + ] + + onTriggered: action => { + contextMenu.close(); + PanelService.closeContextMenu(screen); + if (action === "settings") { + BarService.openPluginSettings(root.screen, pluginApi.manifest); + } + } + } + + onRightClicked: { + if (pluginApi) { + PanelService.showContextMenu(contextMenu, root, screen); + } + } +} diff --git a/clamshell/CLAUDE.md b/clamshell/CLAUDE.md new file mode 100644 index 000000000..ab1cae2c9 --- /dev/null +++ b/clamshell/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Noctalia Shell plugin (`clamshell`) that manages clamshell mode on laptops. When an external monitor is connected, it inhibits the lid-switch suspend via `systemd-inhibit`; when no external monitor is present, normal systemd behavior is restored. Full specification is in `noctalia-clamshell-spec.md`. + +**Target platform:** CachyOS (Arch-based), niri ≥ 0.1.9, Noctalia Shell ≥ 3.6.0, Quickshell (via `noctalia-qs`). + +**Install path:** `~/.config/noctalia/plugins/clamshell/` + +## Planned File Structure + +``` +clamshell/ +├── manifest.json +├── Main.qml # background logic: event-stream, inhibitor Process +├── ControlCenterWidget.qml # toggle button +├── BarWidget.qml # status icon +├── Settings.qml # settings UI +├── preview.png +├── README.md +└── i18n/ + ├── en.json + └── ru.json +``` + +## Architecture + +### State Model (three sources of truth) + +| Property | Type | Source | Description | +|---|---|---|---| +| `enabled` | bool rw | `pluginSettings` | UI toggle — is the plugin active? | +| `externalPresent` | bool ro | niri event-stream | Is at least one external monitor connected? | +| `inhibitorActive` | bool derived | `enabled && externalPresent` | Is the systemd-inhibit process running? | + +Any change to `enabled` or `externalPresent` triggers a recalculation and starts/stops the inhibitor process. + +### Main.qml is the single source of logic + +Widgets get all state via `pluginApi.mainInstance.*`. Widgets must **not** spawn their own `niri msg` processes or duplicate JSON parsing logic. + +`Main.qml` must export via `pluginApi.mainInstance`: +- Properties: `enabled` (rw), `externalPresent` (ro), `inhibitorActive` (ro), `outputs` (ro, list) +- Signal: `stateChanged()` +- Methods: `toggle()`, `enable()`, `disable()`, `refresh()`, `status()` + +### Event-stream (niri monitor detection) + +Use `Quickshell.Io.Process` to run `niri msg --json event-stream`. Read stdout line-by-line — each line is a JSON event. The stream sends full current state upfront, so a separate `niri msg --json outputs` call at startup is **not needed**. + +On any output-related event: call `niri msg --json outputs`, filter by `internalConnectorRegex` (default `^(eDP|LVDS|DSI)`), set `externalPresent`. Unknown event types must be silently ignored (niri documents this explicitly). + +Reconnect strategy on crash: wait 2 s, retry with exponential backoff up to 30 s. Do **not** kill the inhibitor during stream downtime. + +### Inhibitor process lifecycle + +Command: +```sh +systemd-inhibit --what=handle-lid-switch --who= \ + --why="Clamshell mode — external display in use" --mode=block \ + sleep infinity +``` + +Bind `Process.running = inhibitorActive` declaratively. Verify cleanup in `Component.onDestruction`. If `systemd-inhibit` is missing from PATH: log error, show UI error, treat plugin as permanently `enabled=false`. + +### IPC target: `plugin:clamshell` + +Functions: `enable`, `disable`, `toggle`, `status`, `refresh`. +`status` returns JSON — see spec §3.5 for schema. + +## Key Implementation Rules + +- **No polling** — `niri msg outputs` is called reactively from event-stream only, never on a timer. +- **No logind.conf changes** — the plugin must not modify `/etc/systemd/logind.conf`. +- **Single inhibitor process** — at most one `systemd-inhibit` process at any time (`pgrep -f noctalia-clamshell` should return ≤ 1 result). +- **Logging** — use `qs.Commons.Logger`, never `console.log` in production code. +- **No duplicate processes** — test for leaks by connect/disconnect cycling 10× and checking `pgrep`. + +## Development & Testing + +There is no build step. The plugin is loaded directly by Noctalia Shell from the install path. + +**Manual IPC testing:** +```sh +qs -c noctalia-shell ipc call plugin:clamshell status +qs -c noctalia-shell ipc call plugin:clamshell toggle +systemd-inhibit --list # verify inhibitor presence/absence +``` + +**Log inspection:** +```sh +journalctl --user -u noctalia* -f +``` + +**Acceptance scenarios** are fully defined in spec §9. The plugin is done when all 18 criteria pass. + +## Key References + +- niri IPC events/outputs: https://yalter.github.io/niri/IPC.html and https://docs.rs/niri-ipc/ +- Noctalia plugin docs: https://docs.noctalia.dev/development/plugins/overview/ +- Hello-world plugin example: https://github.com/noctalia-dev/noctalia-plugins (`hello-world/`) +- `man systemd-inhibit` — especially `--what=handle-lid-switch` and `--mode=block` diff --git a/clamshell/ControlCenterWidget.qml b/clamshell/ControlCenterWidget.qml new file mode 100644 index 000000000..dde237b75 --- /dev/null +++ b/clamshell/ControlCenterWidget.qml @@ -0,0 +1,27 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Widgets + +NIconButtonHot { + id: root + + property ShellScreen screen + property var pluginApi: null + + readonly property var main: pluginApi ? pluginApi.mainInstance : null + readonly property bool isActive: !!main && main.enabled && main.externalPresent + readonly property bool isStandby: !!main && main.enabled && !main.externalPresent + readonly property bool isDisabled: !main || !main.enabled + readonly property string stateText: main?.stateLabel ? main.stateLabel() : pluginApi?.tr("state.disabled") + readonly property string outputsText: main?.outputSummary ? main.outputSummary() : "" + + icon: "device-desktop" + tooltipText: outputsText ? stateText + "\n" + outputsText : stateText + + onClicked: { + if (root.main) { + root.main.toggle(); + } + } +} diff --git a/clamshell/Main.qml b/clamshell/Main.qml new file mode 100644 index 000000000..c08509f0b --- /dev/null +++ b/clamshell/Main.qml @@ -0,0 +1,513 @@ +// Main.qml — clamshell plugin background logic. +// +// Responsibilities: +// - Watch niri's `event-stream` for output-related events. +// - On any output-related event, query `niri msg --json outputs` and recompute +// `externalPresent` against `internalConnectorRegex`. +// - Maintain reconnection with exponential backoff if the event-stream dies. +// - Detect at startup whether `systemd-inhibit` is available on PATH. +// - Own the single `systemd-inhibit` Process and plugin IPC target. +// - Expose state to widgets through Noctalia's pluginApi.mainInstance. +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Services.UI + +Item { + id: root + + // ───── Plugin API injection ────────────────────────────────────────── + // Noctalia's PluginService injects this. Settings are read with the + // standard cfg/defaults fallback chain documented in AGENTS.md. + property var pluginApi: null + + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings + || pluginApi?.manifest?.defaultSettings + || ({}) + + readonly property string internalConnectorRegex: cfg.internalConnectorRegex + ?? defaults.internalConnectorRegex + ?? "^(eDP|LVDS|DSI)" + readonly property bool notifyEnabled: cfg.notify !== undefined + ? cfg.notify + : (defaults.notify !== undefined ? defaults.notify : true) + readonly property string inhibitorWho: cfg.inhibitorWho + ?? defaults.inhibitorWho + ?? "noctalia-clamshell" + + // ───── Public state (consumed by widgets via mainInstance) ─────────── + // `enabled` is the user-facing toggle. Writes are immediately persisted + // because widgets and IPC mutate this property directly. + property bool enabled: (cfg.enabled !== undefined) + ? cfg.enabled + : (defaults.enabled !== undefined ? defaults.enabled : true) + + // `externalPresent` reflects whether at least one non-internal output is + // physically connected (modes array non-empty), regardless of whether + // the user has disabled it via `niri msg output ... off`. + property bool externalPresent: false + + readonly property bool inhibitorActive: inhibitorProc.running + + // `inhibitorAvailable` is set by the `which systemd-inhibit` probe + // below. While the probe is in flight we optimistically assume true. + property bool inhibitorAvailable: true + + // `outputs` is the canonical, deduplicated list of monitor descriptors + // that the IPC `status` call (Task 4) and Settings UI consume. Each + // entry is { name, internal, connected, active }. + property var outputs: [] + property var lastOutputsPayload: null + + readonly property int inhibitorPid: inhibitorProc.processId ? Number(inhibitorProc.processId) : 0 + property bool componentReady: false + + // Emitted whenever any of the three primary state-bools change. This is + // part of the public mainInstance contract (spec §8). + signal stateChanged + + onEnabledChanged: { + persistEnabled(); + stateChanged(); + } + onExternalPresentChanged: stateChanged() + onInhibitorActiveChanged: stateChanged() + + // ───── Backoff state for event-stream reconnection ─────────────────── + // We start at 2 s and double up to 30 s; reset to 2 s on any successful + // connect (i.e. when the process emits `started`). During downtime we + // deliberately preserve the last-known `externalPresent` so that a + // transient niri restart doesn't drop the inhibitor (spec §4.4). + readonly property int backoffMinMs: 2000 + readonly property int backoffMaxMs: 30000 + property int currentBackoffMs: backoffMinMs + + // ───── Logging tag ─────────────────────────────────────────────────── + readonly property string logTag: "Clamshell" + + // ───────────────────────────────────────────────────────────────────── + // Public methods (spec §8). + function enable() { + if (!root.inhibitorAvailable) { + root.enabled = false; + return "error:no-systemd-inhibit"; + } + root.enabled = true; + return "ok"; + } + + function disable() { + root.enabled = false; + return "ok"; + } + + function toggle() { + if (root.enabled) { + root.disable(); + } else { + root.enable(); + } + return root.enabled ? "on" : "off"; + } + + function refresh() { + fetchOutputs(); + return "ok"; + } + + function applySettings() { + root.enabled = (cfg.enabled !== undefined) + ? cfg.enabled + : (defaults.enabled !== undefined ? defaults.enabled : true); + if (root.lastOutputsPayload) { + classifyOutputs(root.lastOutputsPayload); + } + } + + function persistEnabled() { + if (!pluginApi || !pluginApi.pluginSettings) return; + if (pluginApi.pluginSettings.enabled === root.enabled) return; + pluginApi.pluginSettings.enabled = root.enabled; + pluginApi.saveSettings(); + } + + function status() { + var out = []; + for (var i = 0; i < root.outputs.length; ++i) { + var o = root.outputs[i]; + out.push({ + name: o.name, + internal: !!o.internal, + active: !!o.active + }); + } + return JSON.stringify({ + enabled: root.enabled, + externalPresent: root.externalPresent, + inhibitorActive: root.inhibitorActive, + inhibitorAvailable: root.inhibitorAvailable, + inhibitorPid: root.inhibitorPid, + outputs: out + }); + } + + function stateLabel() { + if (!root.enabled) return pluginApi?.tr("state.disabled"); + if (root.externalPresent) return pluginApi?.tr("state.active"); + return pluginApi?.tr("state.standby"); + } + + function outputSummary() { + var parts = []; + for (var i = 0; i < root.outputs.length; ++i) { + var o = root.outputs[i]; + parts.push(o.name + (o.internal ? " (internal)" : " (external)")); + } + return parts.join(", "); + } + + IpcHandler { + target: "plugin:clamshell" + + function enable() { + return root.enable(); + } + + function disable() { + return root.disable(); + } + + function toggle() { + return root.toggle(); + } + + function refresh() { + return root.refresh(); + } + + function status() { + return root.status(); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Output classification. + // + // niri's `Outputs` response is a JSON object keyed by output name + // (HashMap in niri-ipc). Each Output has at minimum: + // { name, modes: [...], current_mode: int|null, ... } + // + // Connected: a monitor is physically present iff `modes` is a non-empty + // array. Disabled-by-user (`niri msg output X off`) yields current_mode + // = null but modes is still populated. + // + // External: name does not match the internalConnectorRegex. We compile + // the regex once per fetch (cheap, and accounts for Settings changes). + function classifyOutputs(outputsObj) { + root.lastOutputsPayload = outputsObj; + + var rx; + try { + rx = new RegExp(root.internalConnectorRegex); + } catch (e) { + Logger.w(root.logTag, "Invalid internalConnectorRegex:", root.internalConnectorRegex, "— treating all outputs as external. Error:", e); + rx = null; + } + + var list = []; + var hasExternal = false; + + // Outputs response may legally be either an object map (current + // niri-ipc) or an array (older niri builds / fixtures). Handle both. + if (Array.isArray(outputsObj)) { + for (var i = 0; i < outputsObj.length; ++i) { + var entry = outputsObj[i]; + if (!entry || typeof entry.name !== "string") continue; + var rec = makeOutputRecord(entry, entry.name, rx); + list.push(rec); + if (!rec.internal && rec.connected) hasExternal = true; + } + } else if (outputsObj && typeof outputsObj === "object") { + var names = Object.keys(outputsObj); + for (var j = 0; j < names.length; ++j) { + var name = names[j]; + var data = outputsObj[name] || {}; + var rec2 = makeOutputRecord(data, name, rx); + list.push(rec2); + if (!rec2.internal && rec2.connected) hasExternal = true; + } + } + + // Stable order — consumers (Settings UI) expect a deterministic list. + list.sort(function(a, b) { return a.name.localeCompare(b.name); }); + + root.outputs = list; + root.externalPresent = hasExternal; + } + + function makeOutputRecord(data, name, rx) { + var modes = Array.isArray(data.modes) ? data.modes : []; + var connected = modes.length > 0; + var active = data.current_mode !== null && data.current_mode !== undefined; + var internal = rx ? rx.test(name) : false; + return { + name: name, + internal: internal, + connected: connected, + active: active + }; + } + + // ───────────────────────────────────────────────────────────────────── + // niri-related processes. + // + // `outputsProc` — one-shot. Fetches the full outputs map; uses + // StdioCollector since the response is a single JSON document. + // + // `eventStreamProc` — long-lived. Each line is one Event JSON object; + // SplitParser delivers one signal per newline-delimited chunk. + // + // `inhibitProbeProc` — one-shot at startup, checks `which systemd-inhibit` + // and flips inhibitorAvailable based on exit code. + + function fetchOutputs() { + if (outputsProc.running) { + // Coalesce: a fetch is already in flight. The active fetch will + // reflect the most recent state once it returns. niri events are + // edge-triggered and we always re-query on the next event anyway. + return; + } + outputsProc.running = true; + } + + Process { + id: outputsProc + command: ["niri", "msg", "--json", "outputs"] + + stdout: StdioCollector { + onStreamFinished: { + var raw = this.text; + if (!raw) { + Logger.w(root.logTag, "niri outputs returned empty payload"); + return; + } + try { + var parsed = JSON.parse(raw); + classifyOutputs(parsed); + Logger.d(root.logTag, "Outputs refreshed; externalPresent=" + root.externalPresent + ", count=" + root.outputs.length); + } catch (e) { + Logger.w(root.logTag, "Failed to parse niri outputs JSON:", e, "raw[0..200]=", raw.substring(0, 200)); + } + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + Logger.w(root.logTag, "niri msg outputs exited with code", exitCode); + } + } + } + + // ───── Event-stream consumer ───────────────────────────────────────── + // niri-ipc Event variants of interest are anything containing "Output" + // in the top-level key (see niri-ipc::Event). Unknown event variants + // are silently ignored — niri documents that consumers must tolerate + // future additions. + Process { + id: eventStreamProc + command: ["niri", "msg", "--json", "event-stream"] + + // Don't auto-start: Component.onCompleted starts it after the + // inhibitor-availability probe schedules so that startup ordering + // is deterministic. + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => root.handleEventLine(data) + } + + stderr: SplitParser { + splitMarker: "\n" + onRead: data => { + if (data && data.length > 0) { + Logger.w(root.logTag, "event-stream stderr:", data); + } + } + } + + onStarted: { + Logger.i(root.logTag, "niri event-stream connected"); + // Successful connect — reset backoff and refresh outputs. + // We don't trust the stream's initial frames to carry output + // state (current niri-ipc has no Outputs* event), so we always + // do an explicit one-shot fetch on (re)connect. + root.currentBackoffMs = root.backoffMinMs; + root.fetchOutputs(); + } + + onExited: (exitCode, exitStatus) => { + if (!root.componentReady) { + return; + } + Logger.w(root.logTag, "niri event-stream exited; code=" + exitCode + " status=" + exitStatus + " — reconnect in " + root.currentBackoffMs + "ms"); + // Preserve last-known externalPresent during downtime — see + // spec §4.4: do NOT drop the inhibitor on a transient stream + // failure. + backoffTimer.interval = root.currentBackoffMs; + backoffTimer.restart(); + // Double the next delay (clamped). Reset happens in onStarted. + root.currentBackoffMs = Math.min(root.currentBackoffMs * 2, root.backoffMaxMs); + } + } + + function handleEventLine(line) { + var trimmed = (line || "").trim(); + if (!trimmed) return; + + var parsed; + try { + parsed = JSON.parse(trimmed); + } catch (e) { + Logger.w(root.logTag, "Failed to parse event-stream line:", e, "raw=", trimmed.substring(0, 200)); + return; + } + if (!parsed || typeof parsed !== "object") return; + + var keys = Object.keys(parsed); + for (var i = 0; i < keys.length; ++i) { + if (keys[i].indexOf("Output") !== -1) { + // Fast path: known output-related event — fetch immediately. + Logger.d(root.logTag, "Output-related event:", keys[i]); + root.fetchOutputs(); + return; + } + } + + // §4.2 fallback: niri-ipc currently has no Output* Event variants. + // Re-fetch outputs on any event so real-time hotplug is detected + // (niri generates WorkspacesChanged etc. when monitor topology changes). + // The debounce timer coalesces rapid bursts into a single fetch. + outputsDebounce.restart(); + } + + Timer { + id: backoffTimer + repeat: false + interval: root.backoffMinMs + onTriggered: { + if (!eventStreamProc.running) { + Logger.i(root.logTag, "Reconnecting niri event-stream..."); + eventStreamProc.running = true; + } + } + } + + // Debounce timer for the §4.2 fallback: any niri event triggers an + // outputs re-fetch, but we batch rapid bursts to avoid spawning a + // subprocess per event. 300 ms is long enough to coalesce a burst but + // short enough to feel responsive on hotplug. + Timer { + id: outputsDebounce + repeat: false + interval: 300 + onTriggered: root.fetchOutputs() + } + + // ───── systemd-inhibit availability probe ──────────────────────────── + // Runs once at startup. If `which systemd-inhibit` exits non-zero we + // flip `inhibitorAvailable` to false and the UI/widgets will treat the + // plugin as permanently inert (spec §5.3). + Process { + id: inhibitProbeProc + command: ["which", "systemd-inhibit"] + // StdioCollector silences stdout — we only care about the exit code. + stdout: StdioCollector {} + stderr: StdioCollector {} + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.inhibitorAvailable = true; + Logger.d(root.logTag, "systemd-inhibit found"); + } else { + root.inhibitorAvailable = false; + root.enabled = false; + Logger.w(root.logTag, "systemd-inhibit not found in PATH (which exit=" + exitCode + ") — clamshell inhibition disabled"); + } + } + } + + // ───── systemd-inhibit lifecycle ──────────────────────────────────── + // Quickshell sends SIGTERM when Process.running becomes false and kills + // tracked children when the shell exits. Binding `running` keeps the + // process count at zero or one. + Process { + id: inhibitorProc + running: root.enabled && root.externalPresent && root.inhibitorAvailable + command: [ + "systemd-inhibit", + "--what=handle-lid-switch", + "--who=" + root.inhibitorWho, + "--why=Clamshell mode - external display in use", + "--mode=block", + "sleep", "infinity" + ] + + stdout: StdioCollector {} + stderr: SplitParser { + splitMarker: "\n" + onRead: data => { + if (data && data.length > 0) { + Logger.w(root.logTag, "systemd-inhibit stderr:", data); + } + } + } + + onStarted: { + Logger.i(root.logTag, "systemd-inhibit started; pid=" + root.inhibitorPid); + root.notifyInhibitorChange(true); + } + + onExited: (exitCode, exitStatus) => { + Logger.i(root.logTag, "systemd-inhibit exited; code=" + exitCode + " status=" + exitStatus); + root.notifyInhibitorChange(false); + } + } + + function notifyInhibitorChange(active) { + if (!root.componentReady || !root.notifyEnabled) return; + + var msg = active + ? pluginApi?.tr("notify.on") + : pluginApi?.tr("notify.off"); + ToastService.showNotice("Clamshell Mode", msg, "device-desktop"); + } + + // ───────────────────────────────────────────────────────────────────── + Component.onCompleted: { + root.componentReady = true; + Logger.i(root.logTag, "Main.qml initialised; internalConnectorRegex=" + root.internalConnectorRegex); + // Probe systemd-inhibit availability first (cheap, async). + inhibitProbeProc.running = true; + // Start the event stream. Initial outputs fetch happens in + // eventStreamProc.onStarted so we always have a fresh snapshot + // immediately after the stream comes up. + eventStreamProc.running = true; + } + + Component.onDestruction: { + root.componentReady = false; + // Stop the long-lived stream cleanly so we don't leak processes + // across Noctalia reloads. Quickshell's Process should SIGTERM on + // running=false, but we stop the backoff timer too so a queued + // reconnect can't race with destruction. + backoffTimer.stop(); + if (eventStreamProc.running) { + eventStreamProc.running = false; + } + if (inhibitorProc.running) { + inhibitorProc.running = false; + } + } +} diff --git a/clamshell/README.md b/clamshell/README.md new file mode 100644 index 000000000..38124b6e2 --- /dev/null +++ b/clamshell/README.md @@ -0,0 +1,58 @@ +# Clamshell Mode Plugin for Noctalia Shell + +Automatically inhibits lid-switch suspend when an external monitor is connected. +When the lid is closed with an external display active, niri turns off the +internal screen while the system stays awake. + +## Requirements + +- Noctalia Shell 3.6.0 or newer +- niri 0.1.9 or newer +- systemd with `systemd-inhibit` +- Quickshell through `noctalia-qs` + +## Installation + +Place this directory at: + +```sh +~/.config/noctalia/plugins/clamshell/ +``` + +Register and enable the plugin in Noctalia, then add the bar widget or control +center widget from Noctalia settings. + +## IPC + +```sh +qs -c noctalia-shell ipc call plugin:clamshell status +qs -c noctalia-shell ipc call plugin:clamshell toggle +qs -c noctalia-shell ipc call plugin:clamshell enable +qs -c noctalia-shell ipc call plugin:clamshell disable +qs -c noctalia-shell ipc call plugin:clamshell refresh +``` + +`status` returns JSON with `enabled`, `externalPresent`, `inhibitorActive`, the +inhibitor PID, and the detected outputs. + +## niri Keybinding + +Add a binding like this to `~/.config/niri/config.kdl`: + +```kdl +binds { + Mod+Shift+L { spawn "sh" "-c" "qs -c noctalia-shell ipc call plugin:clamshell toggle"; } +} +``` + +## logind.conf + +Leave `/etc/systemd/logind.conf` at the normal lid-switch behavior, typically +`HandleLidSwitch=suspend`. This plugin does not edit system configuration; it +uses `systemd-inhibit --mode=block` only while clamshell mode is active. + +## Known Limitation + +If the lid is already closed when the last external monitor is disconnected, +logind will not receive another lid-close event until the lid is opened and +closed again. diff --git a/clamshell/Settings.qml b/clamshell/Settings.qml new file mode 100644 index 000000000..b027f1e98 --- /dev/null +++ b/clamshell/Settings.qml @@ -0,0 +1,146 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + readonly property var main: pluginApi ? pluginApi.mainInstance || ({}) : ({}) + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + + property bool editEnabled: cfg.enabled !== undefined + ? cfg.enabled + : (defaults.enabled !== undefined ? defaults.enabled : true) + property bool editAlwaysShowBarWidget: cfg.alwaysShowBarWidget !== undefined + ? cfg.alwaysShowBarWidget + : (defaults.alwaysShowBarWidget !== undefined ? defaults.alwaysShowBarWidget : false) + property bool editNotify: cfg.notify !== undefined + ? cfg.notify + : (defaults.notify !== undefined ? defaults.notify : true) + property string editInternalConnectorRegex: cfg.internalConnectorRegex + ?? defaults.internalConnectorRegex + ?? "^(eDP|LVDS|DSI)" + property string editInhibitorWho: cfg.inhibitorWho + ?? defaults.inhibitorWho + ?? "noctalia-clamshell" + + spacing: Style.marginL + + Component.onCompleted: { + Logger.d("Clamshell", "Settings UI loaded"); + } + + ColumnLayout { + spacing: Style.marginM + Layout.fillWidth: true + + NToggle { + label: pluginApi?.tr("settings.enabled") + checked: root.editEnabled + onToggled: checked => root.editEnabled = checked + } + + NToggle { + label: pluginApi?.tr("settings.alwaysShowBarWidget") + checked: root.editAlwaysShowBarWidget + onToggled: checked => root.editAlwaysShowBarWidget = checked + } + + NToggle { + label: pluginApi?.tr("settings.notify") + checked: root.editNotify + onToggled: checked => root.editNotify = checked + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.internalConnectorRegex") + placeholderText: "^(eDP|LVDS|DSI)" + text: root.editInternalConnectorRegex + onTextChanged: root.editInternalConnectorRegex = text + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.inhibitorWho") + placeholderText: "noctalia-clamshell" + text: root.editInhibitorWho + onTextChanged: root.editInhibitorWho = text + } + } + + NText { + visible: root.main.inhibitorAvailable === false + Layout.fillWidth: true + text: pluginApi?.tr("error.noInhibit") + color: Color.mError + pointSize: Style.fontSizeM + wrapMode: Text.WordWrap + } + + ColumnLayout { + spacing: Style.marginS + Layout.fillWidth: true + + NText { + text: pluginApi?.tr("status.title") + color: Color.mOnSurface + pointSize: Style.fontSizeL + font.weight: Font.DemiBold + } + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("status.state", { + "value": root.main.stateLabel ? root.main.stateLabel() : pluginApi?.tr("status.unknown") + }) + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + } + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("status.inhibitorPid", { + "value": root.main.inhibitorPid > 0 ? String(root.main.inhibitorPid) : "-" + }) + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + } + + Repeater { + model: root.main.outputs || [] + + NText { + Layout.fillWidth: true + text: modelData.name + ": " + + (modelData.internal ? "internal" : "external") + + ", " + + (modelData.active ? "active" : (modelData.connected ? "connected" : "disconnected")) + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + } + } + } + + function saveSettings() { + if (!pluginApi) { + Logger.e("Clamshell", "Cannot save settings: pluginApi is null"); + return; + } + + pluginApi.pluginSettings.enabled = root.editEnabled; + pluginApi.pluginSettings.alwaysShowBarWidget = root.editAlwaysShowBarWidget; + pluginApi.pluginSettings.notify = root.editNotify; + pluginApi.pluginSettings.internalConnectorRegex = root.editInternalConnectorRegex; + pluginApi.pluginSettings.inhibitorWho = root.editInhibitorWho; + pluginApi.saveSettings(); + + if (root.main.applySettings) { + root.main.applySettings(); + } + Logger.i("Clamshell", "Settings saved successfully"); + } +} diff --git a/clamshell/i18n/en.json b/clamshell/i18n/en.json new file mode 100644 index 000000000..5e6427827 --- /dev/null +++ b/clamshell/i18n/en.json @@ -0,0 +1,31 @@ +{ + "state": { + "active": "On - external display detected", + "standby": "Auto - no external display", + "disabled": "Off" + }, + "notify": { + "on": "Clamshell ON - lid switch inhibited", + "off": "Clamshell OFF - normal lid behavior" + }, + "settings": { + "enabled": "Enable clamshell mode", + "alwaysShowBarWidget": "Always show bar icon", + "notify": "Show notifications", + "internalConnectorRegex": "Internal connector pattern", + "inhibitorWho": "Inhibitor identifier" + }, + "status": { + "title": "Status", + "state": "State: {value}", + "inhibitorPid": "Inhibitor PID: {value}", + "unknown": "Unknown" + }, + "error": { + "noInhibit": "systemd-inhibit not found - plugin disabled", + "noSocket": "niri socket unavailable" + }, + "menu": { + "settings": "Settings" + } +} diff --git a/clamshell/i18n/ru.json b/clamshell/i18n/ru.json new file mode 100644 index 000000000..a28e648e5 --- /dev/null +++ b/clamshell/i18n/ru.json @@ -0,0 +1,31 @@ +{ + "state": { + "active": "Вкл - внешний монитор подключён", + "standby": "Авто - внешний монитор не обнаружен", + "disabled": "Выкл" + }, + "notify": { + "on": "Clamshell ВКЛ - подавление крышки активно", + "off": "Clamshell ВЫКЛ - штатное поведение крышки" + }, + "settings": { + "enabled": "Включить clamshell-режим", + "alwaysShowBarWidget": "Всегда показывать иконку в баре", + "notify": "Показывать уведомления", + "internalConnectorRegex": "Паттерн внутренних коннекторов", + "inhibitorWho": "Идентификатор инхибитора" + }, + "status": { + "title": "Статус", + "state": "Состояние: {value}", + "inhibitorPid": "PID инхибитора: {value}", + "unknown": "Неизвестно" + }, + "error": { + "noInhibit": "systemd-inhibit не найден - плагин отключён", + "noSocket": "niri socket недоступен" + }, + "menu": { + "settings": "Настройки" + } +} diff --git a/clamshell/manifest.json b/clamshell/manifest.json new file mode 100644 index 000000000..ce5fb7532 --- /dev/null +++ b/clamshell/manifest.json @@ -0,0 +1,33 @@ +{ + "id": "clamshell", + "name": "Clamshell Mode", + "description": "Auto clamshell on external display, with manual override", + "version": "0.1.0", + "author": "Sovego", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "minNoctaliaVersion": "3.6.0", + "tags": ["Bar", "System", "Utility", "Niri"], + "entryPoints": { + "main": "Main.qml", + "controlCenterWidget": "ControlCenterWidget.qml", + "barWidget": "BarWidget.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "enabled": true, + "alwaysShowBarWidget": false, + "notify": true, + "internalConnectorRegex": "^(eDP|LVDS|DSI)", + "inhibitorWho": "noctalia-clamshell" + }, + "ipc": { + "target": "plugin:clamshell", + "functions": ["enable", "disable", "toggle", "status", "refresh"] + } + } +} diff --git a/clamshell/preview.png b/clamshell/preview.png new file mode 100644 index 000000000..1083af9cd Binary files /dev/null and b/clamshell/preview.png differ