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
42 changes: 42 additions & 0 deletions core/internal/server/loginctl/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,45 @@ func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
m.releaseSleepInhibitor()
}
}

// SetLidInhibitorEnabled acquires or releases a block-mode handle-lid-switch
// inhibitor so logind ignores lid close while keep awake is active. logind
// always honors handle-lid-switch locks regardless of LidSwitchIgnoreInhibited.
func (m *Manager) SetLidInhibitorEnabled(enabled bool) error {
m.lidInhibitMu.Lock()
defer m.lidInhibitMu.Unlock()

if !enabled {
m.releaseLidInhibitorLocked()
return nil
}

if m.lidInhibitFile != nil {
return nil
}

if m.managerObj == nil {
return fmt.Errorf("manager object not available")
}

file, err := m.inhibit("handle-lid-switch", "DankMaterialShell", "Keep awake", "block")
if err != nil {
return fmt.Errorf("failed to acquire lid inhibitor: %w", err)
}

m.lidInhibitFile = file
return nil
}

func (m *Manager) releaseLidInhibitor() {
m.lidInhibitMu.Lock()
defer m.lidInhibitMu.Unlock()
m.releaseLidInhibitorLocked()
}

func (m *Manager) releaseLidInhibitorLocked() {
if m.lidInhibitFile != nil {
m.lidInhibitFile.Close()
m.lidInhibitFile = nil
}
}
16 changes: 16 additions & 0 deletions core/internal/server/loginctl/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleSetLockBeforeSuspend(conn, req, manager)
case "loginctl.setSleepInhibitorEnabled":
handleSetSleepInhibitorEnabled(conn, req, manager)
case "loginctl.setLidInhibitorEnabled":
handleSetLidInhibitorEnabled(conn, req, manager)
case "loginctl.lockerReady":
handleLockerReady(conn, req, manager)
case "loginctl.terminate":
Expand Down Expand Up @@ -116,6 +118,20 @@ func handleSetSleepInhibitorEnabled(conn net.Conn, req models.Request, manager *
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
}

func handleSetLidInhibitorEnabled(conn net.Conn, req models.Request, manager *Manager) {
enabled, err := params.Bool(req.Params, "enabled")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}

if err := manager.SetLidInhibitorEnabled(enabled); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lid inhibitor setting updated"})
}

func handleLockerReady(conn net.Conn, req models.Request, manager *Manager) {
manager.lockTimerMu.Lock()
if manager.lockTimer != nil {
Expand Down
107 changes: 107 additions & 0 deletions core/internal/server/loginctl/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"bytes"
"encoding/json"
"net"
"os"
"sync"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -343,6 +345,111 @@ func TestHandleSetIdleHint(t *testing.T) {
})
}

func TestHandleSetLidInhibitorEnabled(t *testing.T) {
t.Run("missing enabled parameter", func(t *testing.T) {
manager := &Manager{
state: &SessionState{},
stateMutex: sync.RWMutex{},
}

conn := newMockNetConn()
req := models.Request{
ID: 123,
Method: "loginctl.setLidInhibitorEnabled",
Params: map[string]any{},
}

handleSetLidInhibitorEnabled(conn, req, manager)

var resp models.Response[any]
err := json.NewDecoder(conn.writeBuf).Decode(&resp)
require.NoError(t, err)

assert.Equal(t, 123, resp.ID)
assert.Contains(t, resp.Error, "missing or invalid 'enabled' parameter")
})

t.Run("disable when not held succeeds", func(t *testing.T) {
manager := &Manager{
state: &SessionState{},
stateMutex: sync.RWMutex{},
}

conn := newMockNetConn()
req := models.Request{
ID: 123,
Method: "loginctl.setLidInhibitorEnabled",
Params: map[string]any{
"enabled": false,
},
}

handleSetLidInhibitorEnabled(conn, req, manager)

var resp models.Response[models.SuccessResult]
err := json.NewDecoder(conn.writeBuf).Decode(&resp)
require.NoError(t, err)

assert.Equal(t, 123, resp.ID)
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
})

t.Run("enable without manager object fails", func(t *testing.T) {
manager := &Manager{
state: &SessionState{},
stateMutex: sync.RWMutex{},
}

conn := newMockNetConn()
req := models.Request{
ID: 123,
Method: "loginctl.setLidInhibitorEnabled",
Params: map[string]any{
"enabled": true,
},
}

handleSetLidInhibitorEnabled(conn, req, manager)

var resp models.Response[any]
err := json.NewDecoder(conn.writeBuf).Decode(&resp)
require.NoError(t, err)

assert.Equal(t, 123, resp.ID)
assert.Contains(t, resp.Error, "manager object not available")
})

t.Run("enable acquires inhibitor once, disable releases it", func(t *testing.T) {
r, w, err := os.Pipe()
require.NoError(t, err)
defer r.Close()
defer w.Close()
fd, err := syscall.Dup(int(r.Fd()))
require.NoError(t, err)

mockManagerObj := mockdbus.NewMockBusObject(t)
mockCall := &dbus.Call{Body: []any{dbus.UnixFD(fd)}}
mockManagerObj.EXPECT().Call(dbusManagerInterface+".Inhibit", dbus.Flags(0), "handle-lid-switch", "DankMaterialShell", "Keep awake", "block").Return(mockCall).Once()

manager := &Manager{
state: &SessionState{},
stateMutex: sync.RWMutex{},
managerObj: mockManagerObj,
}

require.NoError(t, manager.SetLidInhibitorEnabled(true))
require.NotNil(t, manager.lidInhibitFile)

// Already held: no second dbus call (enforced by .Once())
require.NoError(t, manager.SetLidInhibitorEnabled(true))

require.NoError(t, manager.SetLidInhibitorEnabled(false))
assert.Nil(t, manager.lidInhibitFile)
})
}

func TestHandleTerminate(t *testing.T) {
t.Run("successful terminate", func(t *testing.T) {
mockSessionObj := mockdbus.NewMockBusObject(t)
Expand Down
1 change: 1 addition & 0 deletions core/internal/server/loginctl/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ func (m *Manager) Close() {
m.stopSignalPump()

m.releaseSleepInhibitor()
m.releaseLidInhibitor()

m.subscribers.Range(func(key string, ch chan SessionState) bool {
close(ch)
Expand Down
2 changes: 2 additions & 0 deletions core/internal/server/loginctl/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ type Manager struct {
sigWG sync.WaitGroup
inhibitMu sync.Mutex
inhibitFile *os.File
lidInhibitMu sync.Mutex
lidInhibitFile *os.File
lockBeforeSuspend atomic.Bool
inSleepCycle atomic.Bool
sleepCycleID atomic.Uint64
Expand Down
3 changes: 2 additions & 1 deletion core/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)

const APIVersion = 27
const APIVersion = 28

var CLIVersion = "dev"

Expand Down Expand Up @@ -1437,6 +1437,7 @@ func Start(printDocs bool) error {
log.Info(" loginctl.setIdleHint - Set idle hint (params: idle)")
log.Info(" loginctl.setLockBeforeSuspend - Set lock before suspend (params: enabled)")
log.Info(" loginctl.setSleepInhibitorEnabled - Enable/disable sleep inhibitor (params: enabled)")
log.Info(" loginctl.setLidInhibitorEnabled - Enable/disable lid switch inhibitor (params: enabled)")
log.Info(" loginctl.lockerReady - Signal locker UI is ready (releases sleep inhibitor)")
log.Info(" loginctl.terminate - Terminate session")
log.Info(" loginctl.subscribe - Subscribe to session state changes (streaming)")
Expand Down
1 change: 1 addition & 0 deletions quickshell/Common/SettingsData.qml
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ Singleton {
property bool batteryPillPercentSign: false
property bool lockBeforeSuspend: false
property bool loginctlLockIntegration: true
property bool idleInhibitLidSwitch: false
property bool fadeToLockEnabled: true
property int fadeToLockGracePeriod: 5
property bool fadeToDpmsEnabled: true
Expand Down
1 change: 1 addition & 0 deletions quickshell/Common/settings/SettingsSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ var SPEC = {
batteryAutoPowerSaver: { def: false },
lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true },
idleInhibitLidSwitch: { def: false },
fadeToLockEnabled: { def: true },
fadeToLockGracePeriod: { def: 5 },
fadeToDpmsEnabled: { def: true },
Expand Down
10 changes: 10 additions & 0 deletions quickshell/Modules/Settings/PowerSleepTab.qml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ Item {
onToggled: checked => SettingsData.set("lockBeforeSuspend", checked)
}

SettingsToggleRow {
settingKey: "idleInhibitLidSwitch"
tags: ["keep", "awake", "lid", "laptop", "suspend", "inhibit"]
text: I18n.tr("Keep awake blocks lid switch")
description: I18n.tr("While keep awake is active, closing the laptop lid will not suspend the system")
checked: SettingsData.idleInhibitLidSwitch
visible: SessionService.loginctlAvailable
onToggled: checked => SettingsData.set("idleInhibitLidSwitch", checked)
}

SettingsDropdownRow {
id: fadeGracePeriodDropdown
settingKey: "fadeToLockGracePeriod"
Expand Down
24 changes: 24 additions & 0 deletions quickshell/Services/SessionService.qml
Original file line number Diff line number Diff line change
Expand Up @@ -410,13 +410,15 @@ Singleton {
return;
idleInhibited = true;
inhibitorChanged();
syncLidInhibitor();
}

function disableIdleInhibit() {
if (!idleInhibited)
return;
idleInhibited = false;
inhibitorChanged();
syncLidInhibitor();
}

function toggleIdleInhibit() {
Expand Down Expand Up @@ -444,6 +446,7 @@ Singleton {

function onCapabilitiesReceived() {
syncSleepInhibitor();
syncLidInhibitor();
}
}

Expand Down Expand Up @@ -487,6 +490,10 @@ Singleton {
}
syncSleepInhibitor();
}

function onIdleInhibitLidSwitchChanged() {
syncLidInhibitor();
}
}

Connections {
Expand Down Expand Up @@ -615,6 +622,23 @@ Singleton {
});
}

function syncLidInhibitor() {
if (!loginctlAvailable)
return;
if (!DMSService.apiVersion || DMSService.apiVersion < 28)
return;
const enabled = idleInhibited && SettingsData.idleInhibitLidSwitch;
DMSService.sendRequest("loginctl.setLidInhibitorEnabled", {
enabled: enabled
}, response => {
if (response.error) {
log.warn("Failed to sync lid inhibitor:", response.error);
} else {
log.debug("Synced lid inhibitor:", enabled);
}
});
}

function updateLoginctlState(state) {
const wasLocked = locked;
const wasSleeping = preparingForSleep;
Expand Down
25 changes: 25 additions & 0 deletions quickshell/translations/settings_search_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -6980,6 +6980,31 @@
"icon": "schedule",
"description": "Gradually fade the screen before locking with a configurable grace period"
},
{
"section": "idleInhibitLidSwitch",
"label": "Keep awake blocks lid switch",
"tabIndex": 21,
"category": "Power & Sleep",
"keywords": [
"active",
"awake",
"blocks",
"closing",
"energy",
"inhibit",
"keep",
"laptop",
"lid",
"power",
"shutdown",
"sleep",
"suspend",
"switch",
"system",
"while"
],
"description": "While keep awake is active, closing the laptop lid will not suspend the system"
},
{
"section": "fadeToLockGracePeriod",
"label": "Lock fade grace period",
Expand Down
Loading