From 152923f5d86767b49537456ce51a0d8ee157dc97 Mon Sep 17 00:00:00 2001 From: 28allday Date: Sat, 13 Jun 2026 22:08:59 +0100 Subject: [PATCH] fold: bake NoSignal shell patches into the fork (Phase 2) We now own the pinned shell, so the runtime patches that the pacman-hook + patch-script machinery re-applied after every caelestia-shell upgrade are baked directly into source. Folds in all five caelestia-shell-targeting patches from the builder's `os updates/`: - Lock PAM (F1, blocker): ship faillock-free assets/pam.d/caelestia and point modules/lock/Pam.qml's passwd PamContext at it (config "passwd" -> "caelestia"). A desktop screen-lock must never lock the user out of their own session. - Updates page: add modules/nexus/pages/UpdatesPage.qml + register it in the first System slot of PageCompRegistry.qml. - Additions page: add modules/nexus/pages/AdditionsPage.qml + register it in the Plugins slot; relabel Plugins -> Additions in PageRegistry.qml. - Sudo toggle: add modules/nexus/common/SudoToggleRow.qml + insert it into ServicesPage.qml after the Smart colour scheme toggle. - Wi-Fi wrong-password recovery: NetworkConnection.qml saved-profile branch now passes a real callback that forgets the bad profile and reopens the dialog. The builder will drop the corresponding patch-*.sh calls + pacman hooks and bump NOSIGNAL_SHELL_COMMIT to this commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/pam.d/caelestia | 20 ++++ modules/lock/Pam.qml | 7 +- modules/nexus/PageCompRegistry.qml | 14 ++- modules/nexus/PageRegistry.qml | 4 +- modules/nexus/common/SudoToggleRow.qml | 69 ++++++++++++ modules/nexus/pages/AdditionsPage.qml | 120 +++++++++++++++++++++ modules/nexus/pages/ServicesPage.qml | 5 + modules/nexus/pages/UpdatesPage.qml | 139 +++++++++++++++++++++++++ utils/NetworkConnection.qml | 23 +++- 9 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 assets/pam.d/caelestia create mode 100644 modules/nexus/common/SudoToggleRow.qml create mode 100644 modules/nexus/pages/AdditionsPage.qml create mode 100644 modules/nexus/pages/UpdatesPage.qml diff --git a/assets/pam.d/caelestia b/assets/pam.d/caelestia new file mode 100644 index 00000000..26e53f1e --- /dev/null +++ b/assets/pam.d/caelestia @@ -0,0 +1,20 @@ +#%PAM-1.0 +# Caelestia lock-screen authentication (NoSignal). +# +# Deliberately does NOT use pam_faillock: a desktop screen-lock must never lock +# the user out of their OWN running session. With faillock in the path (the old +# "passwd"->system-auth route), a few failed unlocks tripped a temporary account +# lock that then refused the *correct* password until /run/faillock cleared on +# reboot. See README.md / finding F1. +# +# pam_unix verifies the password supplied via the PAM conversation (the lock UI) +# using the setuid unix_chkpwd helper, so it works for the uid-1000 Quickshell +# process. Add pam_systemd_home below if/when systemd-homed users are supported. +# +# F8a: deliberately NO `nullok` — a screen lock must never accept an empty +# password. (Only changes behaviour for empty-password accounts, which are +# correctly rejected; normal password auth is unaffected.) +auth required pam_unix.so +account required pam_unix.so +password required pam_unix.so +session required pam_unix.so diff --git a/modules/lock/Pam.qml b/modules/lock/Pam.qml index f4c61747..c18fc38d 100644 --- a/modules/lock/Pam.qml +++ b/modules/lock/Pam.qml @@ -40,7 +40,12 @@ Scope { PamContext { id: passwd - config: "passwd" + // NoSignal: faillock-free lock auth (finding F1). The stock "passwd" + // service routes through pam_faillock, which can lock the user out of + // their OWN session after a few failed unlocks — refusing even the + // correct password until /run/faillock clears on reboot. The bundled + // "caelestia" service (assets/pam.d/caelestia) uses plain pam_unix. + config: "caelestia" configDirectory: Quickshell.shellDir + "/assets/pam.d" onMessageChanged: { diff --git a/modules/nexus/PageCompRegistry.qml b/modules/nexus/PageCompRegistry.qml index 8c48110b..c7ee1dc4 100644 --- a/modules/nexus/PageCompRegistry.qml +++ b/modules/nexus/PageCompRegistry.qml @@ -73,10 +73,20 @@ QtObject { // System Component { - PlaceholderComp {} + // Updates (NoSignal) + StackPage { + Component { + UpdatesPage {} + } + } }, Component { - PlaceholderComp {} + // Additions (NoSignal) + StackPage { + Component { + AdditionsPage {} + } + } }, // Shell diff --git a/modules/nexus/PageRegistry.qml b/modules/nexus/PageRegistry.qml index ea3ad71e..58c6a7a8 100644 --- a/modules/nexus/PageRegistry.qml +++ b/modules/nexus/PageRegistry.qml @@ -50,9 +50,9 @@ QtObject { category: "system" }, { - label: qsTr("Plugins"), + label: qsTr("Additions"), icon: "extension", - description: qsTr("Manage plugins"), + description: qsTr("Install optional software"), category: "system" }, diff --git a/modules/nexus/common/SudoToggleRow.qml b/modules/nexus/common/SudoToggleRow.qml new file mode 100644 index 00000000..b8d53d91 --- /dev/null +++ b/modules/nexus/common/SudoToggleRow.qml @@ -0,0 +1,69 @@ +// SudoToggleRow.qml (NoSignal) — Settings -> Services toggle for time-boxed +// passwordless sudo. Reflects live state by polling `nosignal-sudo-toggle +// status` (no root needed); enabling opens a floating terminal for the ONE +// password prompt, disabling runs passwordless inside the active window. +// +// Untracked file under modules/nexus/common — auto-discovered as the type +// `SudoToggleRow` via `import qs.modules.nexus.common`. Survives caelestia +// upgrades; only the one-line insert in ServicesPage.qml is re-applied by hook. +import QtQuick +import Quickshell.Io +import qs.modules.nexus.common + +ToggleRow { + id: root + + property bool active: false + property int remaining: 0 + + text: qsTr("Passwordless sudo (15 min)") + subtext: active + ? qsTr("On — %1 min left. Auto-reverts; a reboot also clears it.").arg(remaining) + : qsTr("Run sudo without a password for 15 minutes, then it reverts") + + onToggled: { + if (checked) + enableProc.running = true; // needs a password -> floating terminal + else + disableProc.running = true; // no password inside the active window + reconcile.restart(); + } + + // --- live state ----------------------------------------------------------- + Process { + id: statusProc + command: ["nosignal-sudo-toggle", "status"] + stdout: StdioCollector { + onStreamFinished: { + const p = text.trim().split(/\s+/); + root.active = p[0] === "active"; + root.remaining = parseInt(p[1] || "0") || 0; + root.checked = root.active; // drive switch from real state + } + } + } + + // --- actions -------------------------------------------------------------- + Process { + id: enableProc + command: ["kitty", "--class", "TUI.float", "-e", "sudo", "nosignal-sudo-toggle", "enable"] + } + Process { + id: disableProc + command: ["sudo", "-n", "nosignal-sudo-toggle", "disable"] + } + + // --- polling -------------------------------------------------------------- + Timer { + interval: 5000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: statusProc.running = true + } + Timer { + id: reconcile + interval: 2000 + onTriggered: statusProc.running = true + } +} diff --git a/modules/nexus/pages/AdditionsPage.qml b/modules/nexus/pages/AdditionsPage.qml new file mode 100644 index 00000000..a952962e --- /dev/null +++ b/modules/nexus/pages/AdditionsPage.qml @@ -0,0 +1,120 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +// NoSignal: the Additions settings page (replaces the upstream Plugins +// placeholder). Optional software installed on demand from official sources +// (pacman repos / upstream installers — no AUR, no Flatpak). Items come from +// the additions.json manifest via the status cache written by +// nosignal-additions; Install runs in a visible floating terminal so +// git/sudo/pacman output and prompts stay in front of the user. +PageBase { + id: root + + title: qsTr("Additions") + + property var status: ({}) + readonly property var items: status.items || [] + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // The Process objects live INSIDE the layout (its `data` accepts + // non-visual objects) — PageBase's default property is a single + // `Item`, so declaring them at page level kills the whole shell. + // Same pattern as UpdatesPage / the upstream AboutPage. + + // Read the cached status (instant). + Process { + id: readProc + + running: true + command: ["sh", "-c", "cat \"${XDG_STATE_HOME:-$HOME/.local/state}/nosignal/additions-status.json\" 2>/dev/null"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.status = JSON.parse(text); + } catch (e) { + root.status = {}; + } + } + } + } + + // Refresh the cache on demand (re-runs every item's check). + Process { + id: checkProc + + command: ["sh", "-c", "\"$HOME/.local/bin/nosignal-additions\" status >/dev/null 2>&1"] + onExited: readProc.running = true + } + + // Run an installer in a visible floating terminal. + Process { + id: installProc + + property string addId: "" + + command: ["kitty", "--class", "TUI.float", "-e", "sh", "-c", "\"$HOME/.local/bin/nosignal-additions\" install " + addId + "; printf '\\nPress Enter to close...'; read _"] + onExited: readProc.running = true + } + + SectionHeader { + text: qsTr("Optional software") + } + + Repeater { + model: root.items + + NavRow { + required property var modelData + required property int index + + first: index === 0 + last: index === root.items.length - 1 + icon: modelData.icon || "extension" + label: modelData.name + status: modelData.installed ? qsTr("Installed") : modelData.desc + onClicked: { + if (!modelData.installed && !installProc.running) { + installProc.addId = modelData.id; + installProc.running = true; + } + } + } + } + + InfoRow { + visible: root.items.length === 0 + first: true + last: true + label: qsTr("No additions manifest") + value: "—" + } + + SectionHeader { + text: qsTr("Actions") + } + + NavRow { + first: true + last: true + icon: "refresh" + label: qsTr("Re-check installed state") + status: installProc.running ? qsTr("Install running in terminal…") : (checkProc.running ? qsTr("Checking…") : qsTr("Refreshes the list above")) + onClicked: { + if (!checkProc.running) + checkProc.running = true; + } + } + } +} diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml index 16c92d35..15cb9529 100644 --- a/modules/nexus/pages/ServicesPage.qml +++ b/modules/nexus/pages/ServicesPage.qml @@ -211,6 +211,11 @@ PageBase { onToggled: GlobalConfig.services.smartScheme = checked } + // NoSignal: time-boxed passwordless sudo (15 min) toggle + SudoToggleRow { + Layout.fillWidth: true + } + SelectRow { Layout.fillWidth: true last: true diff --git a/modules/nexus/pages/UpdatesPage.qml b/modules/nexus/pages/UpdatesPage.qml new file mode 100644 index 00000000..d7a7a454 --- /dev/null +++ b/modules/nexus/pages/UpdatesPage.qml @@ -0,0 +1,139 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +// NoSignal: the Updates settings page (replaces the upstream placeholder). +// Reads the JSON cache written by nosignal-update-check; "Update now" runs +// nosignal-update in a floating terminal (sudo + pacman prompts stay visible). +PageBase { + id: root + + title: qsTr("Updates") + + property var status: ({}) + + function count(v) { + return v === undefined ? "…" : String(v); + } + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // The Process objects live INSIDE the layout (its `data` accepts + // non-visual objects) — PageBase's default property is a single + // `Item`, so declaring them at page level kills the whole shell + // ("Cannot assign Process to QQuickItem*"). Same pattern as the + // upstream AboutPage. + + // Read the cached status (instant; the user timer keeps it fresh). + Process { + id: readProc + + running: true + command: ["sh", "-c", "cat \"${XDG_STATE_HOME:-$HOME/.local/state}/nosignal/update-status.json\" 2>/dev/null"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.status = JSON.parse(text); + } catch (e) { + root.status = {}; + } + } + } + } + + // Refresh the cache on demand. + Process { + id: checkProc + + command: ["sh", "-c", "\"$HOME/.local/bin/nosignal-update-check\" >/dev/null 2>&1"] + onExited: readProc.running = true + } + + // Run the real update in a visible floating terminal. + Process { + id: updateProc + + command: ["kitty", "--class", "TUI.float", "-e", "sh", "-c", "\"$HOME/.local/bin/nosignal-update\"; \"$HOME/.local/bin/nosignal-update-check\" >/dev/null 2>&1; printf '\\nPress Enter to close...'; read _"] + onExited: readProc.running = true + } + + SectionHeader { + text: qsTr("Pending updates") + } + + InfoRow { + first: true + label: qsTr("Official packages") + value: root.count(root.status.repo) + } + + InfoRow { + label: qsTr("AUR packages") + value: root.count(root.status.aur) + } + + InfoRow { + label: qsTr("Flatpak") + value: root.count(root.status.flatpak) + } + + InfoRow { + last: true + label: qsTr("NoSignal layer") + subtext: qsTr("Idempotent migrations applied by nosignal-update") + value: root.status.migrations_pending === undefined ? "…" : (root.status.migrations_pending > 0 ? qsTr("%1 migration(s) pending").arg(root.status.migrations_pending) : qsTr("up to date")) + } + + SectionHeader { + text: qsTr("History") + } + + InfoRow { + first: true + label: qsTr("Last full upgrade") + value: root.status.last_upgrade || "—" + } + + InfoRow { + last: true + label: qsTr("Last checked") + value: root.status.checked ? root.status.checked.replace("T", " ").substring(0, 16) : "—" + } + + SectionHeader { + text: qsTr("Actions") + } + + NavRow { + first: true + icon: "refresh" + label: qsTr("Check for updates now") + status: checkProc.running ? qsTr("Checking…") : qsTr("Refreshes the counts above") + onClicked: { + if (!checkProc.running) + checkProc.running = true; + } + } + + NavRow { + last: true + icon: "system_update_alt" + label: qsTr("Update now") + status: updateProc.running ? qsTr("Running in terminal…") : qsTr("Opens a terminal: snapshot, packages, NoSignal layer") + onClicked: { + if (!updateProc.running) + updateProc.running = true; + } + } + } +} diff --git a/utils/NetworkConnection.qml b/utils/NetworkConnection.qml index 8331813d..a7ba68ff 100644 --- a/utils/NetworkConnection.qml +++ b/utils/NetworkConnection.qml @@ -70,7 +70,28 @@ QtObject { const hasSavedProfile = Nmcli.hasSavedProfile(network.ssid); if (hasSavedProfile) { - Nmcli.connectToNetwork(network.ssid, "", network.bssid, null); + // NoSignal wifi-password-retry: a saved profile can hold a wrong + // password. Stock code passed a null callback here, so a failed + // activation never re-prompted — the dialog never reopened. On + // auth failure: forget the bad profile and reopen the dialog + // (same cleanup + dialog path the no-saved-profile branch uses). + Nmcli.connectToNetwork(network.ssid, "", network.bssid, result => { + if (result && result.needsPassword) { + if (Nmcli.pendingConnection) { + Nmcli.connectionCheckTimer.stop(); + Nmcli.immediateCheckTimer.stop(); + Nmcli.immediateCheckTimer.checkCount = 0; + Nmcli.pendingConnection = null; + } + Nmcli.forgetNetwork(network.ssid); + if (session && session.network) { + session.network.showPasswordDialog = true; + session.network.pendingNetwork = network; + } else if (onPasswordNeeded) { + onPasswordNeeded(network); + } + } + }); } else { // Use password check with callback Nmcli.connectToNetworkWithPasswordCheck(network.ssid, network.isSecure, result => {