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",