From f27627ead2efa4b22bd9e7ec3843fe7878ad4382 Mon Sep 17 00:00:00 2001 From: Matt Jiang <67533680+matthewyjiang@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:47:05 -0400 Subject: [PATCH] powermenu: reboot into a chosen EFI boot entry (#3568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 " 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 --- quickshell/Common/SettingsData.qml | 1 + quickshell/Common/settings/SettingsSpec.js | 3 + .../Modules/PowerMenu/PowerMenuContent.qml | 89 +++--------- quickshell/Modules/Settings/PowerSleepTab.qml | 78 ++++++++++ quickshell/Services/AppSearchService.qml | 3 +- quickshell/Services/BootEntries.js | 38 +++++ quickshell/Services/BootEntryService.qml | 137 ++++++++++++++++++ quickshell/Services/SessionService.qml | 30 +++- quickshell/tests/boot-entries.test.mjs | 50 +++++++ .../translations/settings_search_index.json | 29 ++++ 10 files changed, 387 insertions(+), 71 deletions(-) create mode 100644 quickshell/Services/BootEntries.js create mode 100644 quickshell/Services/BootEntryService.qml create mode 100644 quickshell/tests/boot-entries.test.mjs diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 57c8bb9ae..fcb741adc 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -1069,6 +1069,7 @@ Singleton { property string customPowerActionReboot: Spec.SPEC.customPowerActionReboot.def property string customPowerActionPowerOff: Spec.SPEC.customPowerActionPowerOff.def property var customPowerButtons: Spec.SPEC.customPowerButtons.def + property var powerMenuBootEntries: Spec.SPEC.powerMenuBootEntries.def property bool updaterCheckOnStart: Spec.SPEC.updaterCheckOnStart.def property bool updaterUseCustomCommand: Spec.SPEC.updaterUseCustomCommand.def diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 75e66d8de..731629bf3 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -1238,6 +1238,9 @@ var LOCAL_SPEC = { customPowerButtons: { def: [] }, + powerMenuBootEntries: { + def: [] + }, updaterCheckOnStart: { def: false }, diff --git a/quickshell/Modules/PowerMenu/PowerMenuContent.qml b/quickshell/Modules/PowerMenu/PowerMenuContent.qml index 34bdcded3..41dfc3449 100644 --- a/quickshell/Modules/PowerMenu/PowerMenuContent.qml +++ b/quickshell/Modules/PowerMenu/PowerMenuContent.qml @@ -172,8 +172,7 @@ FocusScope { function updateVisibleActions() { const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"]; - const customButtons = SettingsData.customPowerButtons || []; - visibleActions = allActions.filter(action => SessionService.isPowerActionSupported(action)).concat(customButtons.map((button, i) => "custom:" + i)); + visibleActions = allActions.filter(action => SessionService.isPowerActionSupported(action)).concat(SessionService.extraPowerActions); if (!SettingsData.powerMenuGridLayout) return; @@ -260,80 +259,40 @@ FocusScope { handleKeyRelease(event); } + function isEnterKey(event) { + return event.key === Qt.Key_Return || event.key === Qt.Key_Enter; + } + + // The shortcut of each visible action is the key its hint shows. Ctrl combinations navigate. + // fromCharCode truncates to 16 bits, so special keys (0x01xxxxxx) must be rejected first. + function actionForKey(event) { + if (event.modifiers & Qt.ControlModifier || event.key > Qt.Key_Z) + return ""; + const key = String.fromCharCode(event.key); + if (!/^[A-Z0-9]$/.test(key)) + return ""; + return visibleActions.find(action => getActionData(action).key === key) ?? ""; + } + function handleKeyRelease(event) { - if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { + if (isEnterKey(event) || actionForKey(event) !== "") { cancelHold(); event.accepted = true; } } function handleActionShortcut(event) { - switch (event.key) { - case Qt.Key_Return: - case Qt.Key_Enter: + if (isEnterKey(event)) { startHold(getActionAtIndex(selectedIndex), selectedIndex); event.accepted = true; return true; - case Qt.Key_P: - if (!(event.modifiers & Qt.ControlModifier)) { - if (visibleActions.includes("poweroff")) { - startHold("poweroff", visibleActions.indexOf("poweroff")); - event.accepted = true; - return true; - } - } - break; - case Qt.Key_R: - if (visibleActions.includes("reboot")) { - startHold("reboot", visibleActions.indexOf("reboot")); - event.accepted = true; - return true; - } - break; - case Qt.Key_B: - if (visibleActions.includes("softreboot")) { - startHold("softreboot", visibleActions.indexOf("softreboot")); - event.accepted = true; - return true; - } - break; - case Qt.Key_X: - if (visibleActions.includes("logout")) { - startHold("logout", visibleActions.indexOf("logout")); - event.accepted = true; - return true; - } - break; - case Qt.Key_L: - if (visibleActions.includes("lock")) { - startHold("lock", visibleActions.indexOf("lock")); - event.accepted = true; - return true; - } - break; - case Qt.Key_S: - if (visibleActions.includes("suspend")) { - startHold("suspend", visibleActions.indexOf("suspend")); - event.accepted = true; - return true; - } - break; - case Qt.Key_H: - if (visibleActions.includes("hibernate")) { - startHold("hibernate", visibleActions.indexOf("hibernate")); - event.accepted = true; - return true; - } - break; - case Qt.Key_D: - if (visibleActions.includes("restart")) { - startHold("restart", visibleActions.indexOf("restart")); - event.accepted = true; - return true; - } - break; } - return false; + const action = actionForKey(event); + if (action === "") + return false; + startHold(action, visibleActions.indexOf(action)); + event.accepted = true; + return true; } function handleListNavigation(event) { diff --git a/quickshell/Modules/Settings/PowerSleepTab.qml b/quickshell/Modules/Settings/PowerSleepTab.qml index 534777c51..e93336940 100644 --- a/quickshell/Modules/Settings/PowerSleepTab.qml +++ b/quickshell/Modules/Settings/PowerSleepTab.qml @@ -3,6 +3,7 @@ import qs.Common import qs.Services import qs.Widgets import qs.Modules.Settings.Widgets +import "../../Services/BootEntries.js" as BootEntries Item { id: root @@ -273,6 +274,83 @@ Item { } } + SettingsCard { + width: parent.width + iconName: "restart_alt" + title: I18n.tr("Reboot to another OS", "settings card title, lists EFI boot entries the power menu can reboot into") + settingKey: "powerMenuBootEntries" + tags: ["power", "menu", "reboot", "boot", "efi", "uefi", "windows", "dual boot", "bootnext"] + // Saved entries stay listed wherever this config lands, so they can always be removed + visible: SettingsData.powerMenuBootEntries.length > 0 || (BootEntryService.status !== "unknown" && BootEntryService.status !== "noEfi") + + Component.onCompleted: BootEntryService.refresh() + + SettingsRow { + visible: BootEntryService.unavailableReason !== "" + title: BootEntryService.unavailableReason + titleColor: Theme.surfaceVariantText + } + + SettingsRow { + id: bootEntryPicker + + readonly property var options: BootEntries.pickerOptions(BootEntryService.entries, BootEntryService.currentId, SettingsData.powerMenuBootEntries) + + visible: BootEntryService.status === "ready" + title: I18n.tr("Add entry", "settings row that adds an EFI boot entry to the power menu") + + DankDropdown { + id: bootEntryDropdown + enabled: bootEntryPicker.options.length > 0 + Accessible.name: bootEntryPicker.title + width: Math.min(dropdownWidth, bootEntryPicker.width - SettingsMetrics.rowPaddingH * 2) + options: bootEntryPicker.options.map(option => option.name) + emptyText: I18n.tr("Select", "verb, dropdown placeholder or option that opens a picker") + onValueChanged: value => { + const option = bootEntryPicker.options.find(option => option.name === value); + currentValue = ""; + if (!option) + return; + SettingsData.set("powerMenuBootEntries", SettingsData.powerMenuBootEntries.concat([ + { + id: option.id, + label: option.label + } + ])); + } + } + } + + Repeater { + model: SettingsData.powerMenuBootEntries + + delegate: SettingsRow { + id: bootEntryRow + + required property var modelData + required property int index + + title: modelData.label + subtitle: "Boot" + modelData.id + iconName: "restart_alt" + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "delete" + iconColor: Theme.error + Accessible.name: I18n.tr("Remove", "verb, button that removes an item from a list") + onClicked: SettingsData.set("powerMenuBootEntries", SettingsData.powerMenuBootEntries.filter((entry, i) => i !== bootEntryRow.index)) + } + } + } + + SettingsRow { + visible: BootEntryService.status === "ready" && !SettingsData.powerMenuBootEntries.length + title: I18n.tr("No items added yet", "empty list of saved EFI boot entries") + titleColor: Theme.surfaceVariantText + } + } + SettingsCard { width: parent.width iconName: "check_circle" diff --git a/quickshell/Services/AppSearchService.qml b/quickshell/Services/AppSearchService.qml index 9d0da8be5..40395e2a0 100644 --- a/quickshell/Services/AppSearchService.qml +++ b/quickshell/Services/AppSearchService.qml @@ -367,8 +367,7 @@ Singleton { function getPowerLauncherActions() { const ids = ["lock", "logout", "suspend", "hibernate", "reboot", "softreboot", "poweroff", "restart"]; - const customIds = (SettingsData.customPowerButtons || []).map((button, i) => "custom:" + i); - return ids.filter(a => SessionService.isPowerActionSupported(a)).concat(customIds).map(a => { + return ids.filter(a => SessionService.isPowerActionSupported(a)).concat(SessionService.extraPowerActions).map(a => { const data = SessionService.getPowerActionData(a); return { action: a, diff --git a/quickshell/Services/BootEntries.js b/quickshell/Services/BootEntries.js new file mode 100644 index 000000000..9be841323 --- /dev/null +++ b/quickshell/Services/BootEntries.js @@ -0,0 +1,38 @@ +.pragma library + +// Reads efibootmgr output into the current boot id and the active entries as [{id, label}], +// where id is the upper-case hex Boot#### number. +function parseEntries(text) { + const result = { + currentId: "", + entries: [] + }; + for (const line of text.split("\n")) { + const current = line.match(/^BootCurrent:\s*([0-9A-Fa-f]{4})/); + if (current) { + result.currentId = current[1].toUpperCase(); + continue; + } + // efibootmgr 18+ appends a tab and the device path after the label + const entry = line.match(/^Boot([0-9A-Fa-f]{4})(\*?)\s+([^\t]+)/); + if (!entry || entry[2] !== "*") + continue; + result.entries.push({ + id: entry[1].toUpperCase(), + label: entry[3].trim() + }); + } + return result; +} + +// Entries that can still be added to the power menu, skipping the running OS and saved ones. +// name is the text shown in the picker. Firmware can hold several entries with the same label, +// one per disk or install, so those get their id appended. +function pickerOptions(entries, currentId, saved) { + const addable = entries.filter(entry => entry.id !== currentId && !saved.some(item => item.id === entry.id)); + return addable.map(entry => ({ + id: entry.id, + label: entry.label, + name: addable.some(other => other !== entry && other.label === entry.label) ? entry.label + " (" + entry.id + ")" : entry.label + })); +} diff --git a/quickshell/Services/BootEntryService.qml b/quickshell/Services/BootEntryService.qml new file mode 100644 index 000000000..c2c5c2a10 --- /dev/null +++ b/quickshell/Services/BootEntryService.qml @@ -0,0 +1,137 @@ +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()); + } + } +} diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index 34872add0..471d3f357 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -554,6 +554,12 @@ Singleton { } } + // Actions added in settings, listed after the built-in ones: boot entries, then custom buttons. + // Each list lines up index for index with its setting. + readonly property var bootEntryActions: (SettingsData.powerMenuBootEntries || []).map(entry => "bootnext:" + entry.id) + readonly property var customPowerActions: (SettingsData.customPowerButtons || []).map((button, i) => "custom:" + i) + readonly property var extraPowerActions: bootEntryActions.concat(customPowerActions) + function isPowerActionSupported(action) { switch (action) { case "hibernate": @@ -566,13 +572,19 @@ Singleton { } function executePowerAction(action) { - if (action.startsWith("custom:")) { - const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)]; + const customIndex = customPowerActions.indexOf(action); + if (customIndex >= 0) { + const button = SettingsData.customPowerButtons[customIndex]; if (!button?.command) return false; Quickshell.execDetached(customActionCommand(button.command)); return true; } + const bootIndex = bootEntryActions.indexOf(action); + if (bootIndex >= 0) { + BootEntryService.rebootTo(SettingsData.powerMenuBootEntries[bootIndex]); + return true; + } switch (action) { case "logout": logout(); @@ -601,14 +613,24 @@ Singleton { } function getPowerActionData(action) { - if (action.startsWith("custom:")) { - const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)]; + const customIndex = customPowerActions.indexOf(action); + if (customIndex >= 0) { + const button = SettingsData.customPowerButtons[customIndex]; return { "icon": button?.icon || "terminal", "label": button?.label || button?.command || "", "key": "" }; } + const bootIndex = bootEntryActions.indexOf(action); + if (bootIndex >= 0) { + // Boot entries take the digit keys in the order they were added + return { + "icon": "restart_alt", + "label": I18n.tr("Reboot to %1", "power menu action, %1 is a boot entry such as Windows Boot Manager").arg(SettingsData.powerMenuBootEntries[bootIndex].label), + "key": bootIndex < 9 ? String(bootIndex + 1) : "" + }; + } switch (action) { case "reboot": return { diff --git a/quickshell/tests/boot-entries.test.mjs b/quickshell/tests/boot-entries.test.mjs new file mode 100644 index 000000000..34340bb03 --- /dev/null +++ b/quickshell/tests/boot-entries.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import test from "node:test"; + +const bootEntries = vm.createContext({}); +vm.runInContext(readFileSync(new URL("../Services/BootEntries.js", import.meta.url), "utf8").replace(/^\.pragma.*$/m, ""), bootEntries); +const plain = value => JSON.parse(JSON.stringify(value)); + +test("parses active entries and the current boot from efibootmgr output", () => { + // efibootmgr 18 prints the device path after a tab, older releases print only the label + const output = [ + "BootCurrent: 0001", + "Timeout: 1 seconds", + "BootOrder: 0001,0000,0010", + "Boot0000* Windows Boot Manager\tHD(1,GPT,c28482eb,0x1000,0x830000)/\\EFI\\MICROSOFT\\BOOT\\BOOTMGFW.EFI", + "Boot0001* Limine\tHD(1,GPT,c28482eb,0x1000,0x830000)/\\EFI\\LIMINE\\LIMINE_X64.EFI", + "Boot0010 UEFI OS\tHD(1,GPT,c28482eb,0x1000,0x830000)/\\EFI\\BOOT\\BOOTX64.EFI", + "Boot001a* Fedora", + "" + ].join("\n"); + + const parsed = bootEntries.parseEntries(output); + + assert.equal(parsed.currentId, "0001"); + assert.deepEqual(plain(parsed.entries), [ + { id: "0000", label: "Windows Boot Manager" }, + { id: "0001", label: "Limine" }, + { id: "001A", label: "Fedora" } + ]); +}); + +test("picker skips the running and saved entries and tells duplicate labels apart", () => { + const entries = [ + { id: "0000", label: "Windows Boot Manager" }, + { id: "0001", label: "Limine" }, + { id: "0002", label: "Fedora" }, + { id: "0003", label: "Fedora" }, + { id: "0004", label: "Arch" }, + { id: "0005", label: "Arch" } + ]; + const saved = [{ id: "0000", label: "Windows Boot Manager" }, { id: "0004", label: "Arch" }]; + + assert.deepEqual(plain(bootEntries.pickerOptions(entries, "0001", saved)), [ + { id: "0002", label: "Fedora", name: "Fedora (0002)" }, + { id: "0003", label: "Fedora", name: "Fedora (0003)" }, + // Its twin is already saved, so the label alone is unambiguous in the picker + { id: "0005", label: "Arch", name: "Arch" } + ]); +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 836f84cb3..cb42f47f2 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -9271,6 +9271,35 @@ "parentLabel": "Power & battery", "icon": "tune" }, + { + "section": "powerMenuBootEntries", + "label": "Reboot to another OS", + "tabIndex": 21, + "category": "Power & sleep", + "keywords": [ + "another", + "battery", + "boot", + "bootnext", + "charge", + "charging", + "dual boot", + "efi", + "energy", + "hibernate", + "menu", + "power", + "reboot", + "restart", + "shutdown", + "sleep", + "suspend", + "uefi", + "windows" + ], + "parentLabel": "Power & battery", + "icon": "restart_alt" + }, { "section": "suspendBehavior", "label": "Suspend behavior",