Files
DankMaterialShell/quickshell/Services/BootEntryService.qml
T
Matt Jiang f27627ead2 powermenu: reboot into a chosen EFI boot entry (#3568)
* powermenu: derive shortcuts and extra actions from one source

The power menu matched keys with a switch that repeated the key hints
getPowerActionData already returns, plus a second key list for release.
Look the pressed key up against the hints of the visible actions instead,
so every action with a hint gets its shortcut and the two cannot drift.

Side effects:
- Ctrl+letter no longer triggers actions. Only Ctrl+P was excluded
  before, so Ctrl+R still rebooted. Ctrl combinations are navigation.
- U now triggers switch user when it is shown. Its hint already said U.
- Releasing a letter with no visible action no longer cancels a hold.

SessionService now owns the custom:N action ids as customPowerActions
and extraPowerActions, which the power menu and the launcher both use.
Actions resolve by index into that list instead of slicing the prefix.

* powermenu: add actions that reboot into a chosen efi boot entry

Settings → Power & Sleep gets a "Reboot to another OS" card that lists
the firmware's active EFI entries through efibootmgr. Each saved entry
becomes a "Reboot to <name>" action in the power menu and the launcher,
bound to 1-9 in the order it was added.

Picking one lists the entries again, and stops with a toast if the id no
longer carries the saved label. Otherwise it sets BootNext through pkexec
and reboots through SessionService, so custom reboot commands still apply.

BootEntryService reports why entries are unavailable: not booted in UEFI
mode, efibootmgr missing, or efibootmgr failing with its stderr. Saved
entries stay listed in settings whatever the status, so a synced config
on another machine can still remove them.

efibootmgr parsing and the picker's filtering live in BootEntries.js,
covered by tests/boot-entries.test.mjs.

* powermenu: ignore special keys in action shortcuts
2026-09-27 11:47:05 -04:00

138 lines
4.6 KiB
QML

pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import "BootEntries.js" as BootEntries
// Lists EFI boot entries and reboots into one of them once, through the firmware's BootNext.
//
// rebootTo() runs in three steps: list the entries again so a stale saved entry is caught,
// set BootNext through pkexec, then hand the reboot itself to SessionService.
Singleton {
id: root
readonly property var log: Log.scoped("BootEntryService")
// "unknown" until the first refresh, then "ready", "noEfi", "noTool" or "error"
property string status: "unknown"
// efibootmgr's stderr when status is "error"
property string errorText: ""
// Active entries as [{id, label}], where id is the hex Boot#### number
property var entries: []
property string currentId: ""
// Why entries cannot be listed, for display. Empty when status is "ready" or "unknown".
readonly property string unavailableReason: {
switch (status) {
case "noEfi":
return I18n.tr("This system was not started in UEFI mode", "reason EFI boot entries cannot be listed");
case "noTool":
return I18n.tr("Requires %1", "missing program", true).arg("efibootmgr");
case "error":
return errorText || I18n.tr("efibootmgr failed", "error, efibootmgr is a program name and stays untranslated");
default:
return "";
}
}
// The saved entry waiting for the fresh listing before BootNext is set
property var _pendingEntry: null
function refresh() {
listProcess.running = true;
}
function rebootTo(entry) {
if (_pendingEntry || setProcess.running)
return;
_pendingEntry = entry;
listProcess.running = true;
}
function _applyListing(exitCode) {
if (exitCode === 0) {
const parsed = BootEntries.parseEntries(listOutput.text);
entries = parsed.entries;
currentId = parsed.currentId;
errorText = "";
status = "ready";
} else if (exitCode === listProcess.exitNoEfi) {
status = "noEfi";
} else if (exitCode === listProcess.exitNoTool) {
status = "noTool";
} else {
errorText = listErrors.text.trim();
log.warn("efibootmgr failed with exit code", exitCode, errorText);
status = "error";
}
}
function _setPendingBootNext() {
const wanted = _pendingEntry;
_pendingEntry = null;
if (!wanted)
return;
if (status !== "ready") {
ToastService.showError(I18n.tr("Failed to set next boot entry", "error toast, the one-time EFI BootNext entry could not be set"), unavailableReason);
return;
}
const found = entries.find(e => e.id === wanted.id);
if (!found || found.label !== wanted.label) {
ToastService.showError(I18n.tr("Boot entry changed. Remove it and add it again.", "error toast, the firmware renumbered a saved EFI boot entry"), wanted.label);
return;
}
setProcess.command = ["pkexec", "efibootmgr", "--bootnext", wanted.id];
setProcess.running = true;
}
Process {
id: listProcess
// Exit codes of the command's own checks, before efibootmgr runs
readonly property int exitNoEfi: 3
readonly property int exitNoTool: 4
running: false
command: ["sh", "-c", "[ -d /sys/firmware/efi ] || exit " + exitNoEfi + "; command -v efibootmgr > /dev/null || exit " + exitNoTool + "; exec efibootmgr"]
stdout: StdioCollector {
id: listOutput
}
stderr: StdioCollector {
id: listErrors
}
onExited: exitCode => {
root._applyListing(exitCode);
root._setPendingBootNext();
}
}
Process {
id: setProcess
running: false
stderr: StdioCollector {
id: setErrors
}
onExited: exitCode => {
if (exitCode === 0) {
// SessionService owns the reboot so custom reboot commands still apply
SessionService.reboot();
return;
}
// pkexec exits 126 when the user dismisses the auth dialog
if (exitCode === 126) {
root.log.info("BootNext cancelled at the auth prompt");
return;
}
ToastService.showError(I18n.tr("Failed to set next boot entry", "error toast, the one-time EFI BootNext entry could not be set"), setErrors.text.trim());
}
}
}