feat(quickshell): use native Polkit agent

This commit is contained in:
fy59 2026-10-09 07:51:36 +02:00
parent 2de313b94a
commit 195b777b84
12 changed files with 708 additions and 14 deletions

View file

@ -133,7 +133,6 @@ pipewire-alsa
pipewire-jack
pipewire-libcamera
pipewire-pulse
polkit-gnome
podman
powertop
protonmail-bridge

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -87,9 +87,9 @@ Le package
SwayFX 0.6 est un paquet foreign/local à reconstruire depuis sa source
vérifiée ; `scenefx` et `quickshell` sont présents dans la liste Pacman
officielle de cette machine. Voir les inventaires `.assets/`.
`polkit-gnome` fournit l'agent graphique requis pour authentifier les
changements de fuseau effectués par `timedatectl` depuis QuickShell. Le package
`sway` le lance une seule fois à l'ouverture de session via `autostart` ;
QuickShell fournit l'agent graphique Polkit requis pour authentifier les
changements de fuseau effectués par `timedatectl`. Le service utilisateur
`quickshell-labfy-sway.service` le lance une seule fois par session ;
aucune règle `sudoers` propre au fuseau n'est nécessaire.
Le backend batterie exige TLP et `/usr/local/sbin/batlimit-set`, déployé
séparément avec les droits root selon `bin/root/README.md`.

View file

@ -25,6 +25,7 @@ PanelWindow {
required property bool startupHost
required property bool resizeMode
required property var applicationCoordinator
required property bool authenticationActive
required property var keepAwakeController
screen: modelData
@ -64,6 +65,7 @@ PanelWindow {
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
function closeApplications() { applicationCoordinator.applicationsOutput = ""; }
function toggleApplications() {
if (authenticationActive) return false;
if (applicationCoordinator.applicationsOutput !== screen.name) applicationsError = "";
applicationCoordinator.toggleApplications(screen.name);
return true;
@ -100,6 +102,19 @@ PanelWindow {
}
Connections {
target: bar.applicationCoordinator
function onAuthenticationOpening() {
bar.closeApplications();
// INVARIANT: no Overview screenshot may contain the auth surface.
overviewCapture.running = false;
overviewCaptureRefresh.stop();
if (overviewLoader.item) overviewLoader.item.skipDismissCaptures = true;
bar.closeOverview();
bar.closeClipboard();
controlCenter.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
}
function onApplicationsOpening(output) {
bar.closeOverview();
bar.closeClipboard();
@ -145,6 +160,7 @@ PanelWindow {
}
// CONTRACT: clic et IPC suivent la même exclusion que les panneaux de barre.
function toggleClipboard() {
if (authenticationActive) return false;
if (clipboardLoader.active) {
closeClipboard();
} else {
@ -166,6 +182,7 @@ PanelWindow {
if (overviewLoader.item) overviewLoader.item.dismiss();
}
function toggleOverview() {
if (authenticationActive) return false;
if (overviewRequested || overviewLoader.active) { closeOverview(); return true; }
closeApplications();
closeClipboard();
@ -201,7 +218,7 @@ PanelWindow {
id: overviewCapture
command: ["python3", "-B", bar.overviewBackend, "capture", "--output", bar.screen.name]
onExited: (code, status) => {
if (bar.overviewRequested) overviewLoader.active = true;
if (bar.overviewRequested && !bar.authenticationActive) overviewLoader.active = true;
else if (code !== 0) console.warn("Capture du workspace impossible");
}
}
@ -217,7 +234,7 @@ PanelWindow {
id: overviewCaptureRefresh
interval: 350; repeat: false
onTriggered: {
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
if (!bar.authenticationActive && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !applicationsLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
@ -228,7 +245,7 @@ PanelWindow {
Timer {
interval: 15000; repeat: true; running: true; triggeredOnStart: true
onTriggered: {
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
if (!bar.authenticationActive && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !applicationsLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
@ -248,6 +265,7 @@ PanelWindow {
IpcHandler {
target: "appearanceUi-" + bar.screen.name
function openPage(page: string): bool {
if (bar.authenticationActive) return false;
if (controlCenter.pages[page] === undefined) return false;
bar.closeApplications();
bar.closeClipboard();
@ -258,8 +276,12 @@ PanelWindow {
return true;
}
function close(): bool { controlCenter.visible = false; return true; }
function openDateCenter(): bool { bar.toggleDateCenter(); return true; }
function openDateCenter(): bool {
if (bar.authenticationActive) return false;
bar.toggleDateCenter(); return true;
}
function openWindowMenu(): bool {
if (bar.authenticationActive) return false;
bar.closeApplications();
bar.closeClipboard();
controlCenter.visible = false;
@ -296,6 +318,7 @@ PanelWindow {
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
function toggleDateCenter() {
if (authenticationActive) return;
const opening = !dateCenter.visible && !dateCenter.opening;
closeApplications();
closeOverview();

View file

@ -0,0 +1,143 @@
//@ pragma UseQApplication
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Polkit
import "polkit"
ShellRoot {
id: root
property var shownFlow: fakeFlow
QtObject { id: account1; property string displayName: "Compte de test"; property string string: "test"
property int id: 1000; property bool isGroup: false }
QtObject { id: account2; property string displayName: "Autre compte"; property string string: "other"
property int id: 1001; property bool isGroup: false }
QtObject {
id: fakeFlow
property string message: "Autoriser une opération de démonstration sans données personnelles ?"
property string iconName: ""
property string actionId: "org.example.demo"
property var identities: [account1]
property var selectedIdentity: account1
property bool isResponseRequired: true
property string inputPrompt: "Réponse de démonstration :"
property bool responseVisible: false
property string supplementaryMessage: ""
property bool supplementaryIsError: false
property bool isCompleted: false
property bool isCancelled: false
property bool failed: false
property int submissions: 0
signal authenticationFailed()
function submit(value) { submissions++; isResponseRequired = false; }
function cancelAuthenticationRequest() { isCancelled = true; }
}
QtObject {
id: secondFlow
property string message: "Deuxième demande de test"
property string iconName: ""
property string actionId: "org.example.second"
property var identities: [account1]
property var selectedIdentity: account1
property bool isResponseRequired: true
property string inputPrompt: "Réponse visible de test :"
property bool responseVisible: true
property string supplementaryMessage: ""
property bool supplementaryIsError: false
property bool isCompleted: false
property bool isCancelled: false
property bool failed: false
property int submissions: 0
signal authenticationFailed()
function submit(value) { submissions++; isResponseRequired = false; }
function cancelAuthenticationRequest() { isCancelled = true; }
}
IpcHandler {
target: "polkitFixture"
function state(): string {
return JSON.stringify({waiting: fakeFlow.isResponseRequired, cancelled: fakeFlow.isCancelled,
submissions: fakeFlow.submissions, screen: Quickshell.screens.length,
canSubmit: dialogLoader.item ? dialogLoader.item.card.canSubmit : false});
}
function scenario(name: string): bool {
if (name === "long") fakeFlow.message = "Cette opération de démonstration comporte un message volontairement long afin de contrôler le retour à la ligne et la hauteur du dialogue sur un écran compact. Aucune autorisation réelle ne sera demandée.";
else if (name === "multi") fakeFlow.identities = [account1, account2];
else if (name === "identitySamePrompt") {
fakeFlow.identities = [account1, account2];
dialogLoader.item.card.selectIdentity(account2);
}
else if (name === "visible") fakeFlow.responseVisible = true;
else if (name === "error") { fakeFlow.supplementaryMessage = "Échec de test"; fakeFlow.supplementaryIsError = true; fakeFlow.failed = true; fakeFlow.authenticationFailed(); fakeFlow.isResponseRequired = true; }
else if (name === "next") { fakeFlow.isResponseRequired = false; fakeFlow.isResponseRequired = true; }
else if (name === "cancel") fakeFlow.cancelAuthenticationRequest();
else if (name === "reset") {
root.shownFlow = fakeFlow;
fakeFlow.message = "Autoriser une opération de démonstration sans données personnelles ?";
fakeFlow.identities = [account1]; fakeFlow.selectedIdentity = account1;
fakeFlow.inputPrompt = "Réponse de démonstration :";
fakeFlow.responseVisible = false; fakeFlow.supplementaryMessage = "";
fakeFlow.isCancelled = false; fakeFlow.isCompleted = false; fakeFlow.failed = false;
fakeFlow.isResponseRequired = true; fakeFlow.submissions = 0;
if (dialogLoader.item) dialogLoader.item.card.clearResponse();
}
else return false;
return true;
}
function checks(): string {
const card = dialogLoader.item.card;
function findField(item) {
if (item.objectName === "polkitResponse") return item;
for (const child of item.children || []) {
const found = findField(child);
if (found) return found;
}
return null;
}
const field = findField(card);
if (!field) return JSON.stringify({error: "champ absent"});
fakeFlow.isCancelled = false; fakeFlow.isCompleted = false;
fakeFlow.isResponseRequired = true; card.busy = false;
secondFlow.submissions = 0;
field.clear();
const emptySecretBlocked = card.canEdit && !card.canSubmit
&& !card.submitResponse() && fakeFlow.submissions === 0;
field.text = "réponse fictive";
const first = card.submitResponse();
const second = card.submitResponse();
const oneSubmit = first && !second && fakeFlow.submissions === 1 && field.text === "";
fakeFlow.supplementaryMessage = "Échec de test";
fakeFlow.supplementaryIsError = true;
fakeFlow.authenticationFailed(); fakeFlow.isResponseRequired = true;
const retry = card.canEdit && !card.canSubmit && field.text === "";
field.text = "ancienne réponse";
fakeFlow.inputPrompt = "Nouvelle question :";
const successive = field.text === "";
fakeFlow.identities = [account1, account2];
fakeFlow.isResponseRequired = true; card.busy = false;
field.text = "à effacer au changement de compte";
card.selectIdentity(account2);
fakeFlow.inputPrompt = "Nouvelle identité :";
const identity = fakeFlow.selectedIdentity === account2 && field.text === ""
&& card.canEdit && !card.canSubmit;
root.shownFlow = null;
const disappeared = field.text === "" && !card.canSubmit;
root.shownFlow = secondFlow;
secondFlow.isCancelled = false; secondFlow.isResponseRequired = true;
field.text = "réponse fictive 2";
const nextFlow = card.submitResponse() && secondFlow.submissions === 1
&& fakeFlow.submissions === 1 && field.text === "";
root.shownFlow = fakeFlow;
fakeFlow.isCancelled = false; fakeFlow.isResponseRequired = true; card.busy = false;
field.text = "à effacer";
card.cancelRequest();
const cancelled = fakeFlow.isCancelled && field.text === "";
return JSON.stringify({emptySecretBlocked, oneSubmit, retry,
successive, identity, disappeared, nextFlow, cancelled});
}
}
LazyLoader {
id: dialogLoader
active: Quickshell.screens.length > 0
PolkitDialog { requestFlow: root.shownFlow; hostScreen: Quickshell.screens[0] }
}
}

View file

@ -10,7 +10,14 @@ Une sélection propose **Appliquer les deux**, **Fuseau uniquement** et **Positi
## Privilèges et transaction
`timedatectl set-timezone` est appelé directement, avec un tableau d'arguments, via l'authentification Polkit standard de la session. L'agent graphique `polkit-gnome` est lancé au démarrage Sway. QuickShell ne reçoit jamais le mot de passe, ne dispose d'aucune permission root permanente et ne modifie jamais `/etc/localtime` directement. Si l'authentification est annulée ou indisponible, l'opération échoue sans écrire la position solaire.
`timedatectl set-timezone` est appelé directement, avec un tableau d'arguments,
via l'authentification Polkit standard de la session. L'agent graphique natif
QuickShell présente la demande locale : sa fenêtre reçoit temporairement la
réponse masquée, la transmet à `AuthFlow.submit()` et efface le champ. Elle ne
l'écrit ni dans un argument, ni dans un fichier, ni dans un journal. QuickShell
ne dispose d'aucune permission root permanente et ne modifie jamais
`/etc/localtime` directement. Si l'authentification est annulée ou
indisponible, l'opération échoue sans écrire la position solaire.
L'application combinée valide d'abord les entrées, mémorise l'état précédent, change le fuseau, écrit la position locale, réconcilie Night Light en Auto, puis vérifie le fuseau. Un échec de fuseau laisse la position intacte. Un échec ultérieur restaure la préférence et tente de restaurer le fuseau et le service. Un échec de rollback est affiché comme erreur explicite.

View file

@ -20,6 +20,7 @@ PanelWindow {
property bool refreshPending: false
property string actionName: ""
property var dirtyCaptures: []
property bool skipDismissCaptures: false
property var pendingWindowNumbers: []
property var activeWindowNumbers: []
property int windowCaptureCursor: 0
@ -49,7 +50,7 @@ PanelWindow {
visible = false;
// WHY: grim inclurait cette surface overlay. Après sa disparition,
// seules les sorties visibles dont l'arbre a changé sont recapturées.
for (const card of dirtyCaptures) {
for (const card of skipDismissCaptures ? [] : dirtyCaptures) {
Quickshell.execDetached(["python3", "-B", backend, "capture", "--output", card.output,
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
"--revision", card.revision,

View file

@ -0,0 +1,235 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import "../theme"
Item {
id: card
required property var flow
property bool busy: false
property bool awaitingIdentityPrompt: false
property var pendingIdentity: null
property bool detailsOpen: false
readonly property bool waiting: !!flow && flow.isResponseRequired && !flow.isCompleted && !flow.isCancelled
readonly property bool canEdit: waiting && !busy
// INVARIANT: une touche Entrée sur un champ secret vide ne doit pas
// consommer une tentative PAM. Une invite visible peut admettre une
// réponse vide ; Polkit reste maître de sa validation.
readonly property bool canSubmit: canEdit && (flow.responseVisible || answer.text.length > 0)
implicitWidth: 480
implicitHeight: content.implicitHeight + 36
signal cancelled()
// CONTRACT: only the bound native AuthFlow receives the response. The
// field is cleared immediately; no answer enters logs, IPC or storage.
function submitResponse() {
const target = flow;
if (!target || target !== card.flow || !canSubmit) return false;
busy = true;
target.submit(answer.text);
answer.clear();
return true;
}
function cancelRequest() {
const target = flow;
answer.clear();
busy = true;
if (target && !target.isCompleted && !target.isCancelled)
target.cancelAuthenticationRequest();
cancelled();
}
function selectIdentity(identity) {
const target = flow;
if (!target || !target.identities || target.identities.indexOf(identity) < 0) return;
answer.clear();
busy = true;
awaitingIdentityPrompt = true;
pendingIdentity = identity;
target.selectedIdentity = identity;
}
function clearResponse() { answer.clear(); }
function identityLabel(identity) {
if (!identity) return "";
return identity.displayName + " (" + identity.string + " · "
+ (identity.isGroup ? "GID " : "UID ") + identity.id + ")";
}
function focusResponse() { if (waiting) answer.forceActiveFocus(); else cancelButton.forceActiveFocus(); }
onFlowChanged: { answer.clear(); busy = false; awaitingIdentityPrompt = false;
pendingIdentity = null; detailsOpen = false; }
Timer {
id: identityPromptFallback
interval: 1000
running: card.awaitingIdentityPrompt
// WHY: QuickShell 0.3.1 does not expose a session generation. When two
// identities receive the same prompt, its notify values do not change.
// The native setter has already replaced the session synchronously;
// this bounded wait permits the new conversation to present its prompt.
onTriggered: {
if (card.flow && card.flow.selectedIdentity === card.pendingIdentity
&& card.flow.isResponseRequired && !card.flow.isCompleted) {
card.awaitingIdentityPrompt = false;
card.busy = false;
card.focusResponse();
}
}
}
onWaitingChanged: {
if (waiting) {
if (!awaitingIdentityPrompt) busy = false;
answer.clear();
Qt.callLater(focusResponse);
} else answer.clear();
}
Connections {
target: card.flow
function onAuthenticationFailed() { card.busy = false; answer.clear(); }
function onSelectedIdentityChanged() { answer.clear(); }
function onInputPromptChanged() {
answer.clear();
if (card.awaitingIdentityPrompt && card.flow && card.flow.isResponseRequired) {
card.awaitingIdentityPrompt = false;
card.busy = false;
Qt.callLater(card.focusResponse);
}
}
function onIsResponseRequiredChanged() {
if (card.awaitingIdentityPrompt && card.flow && card.flow.isResponseRequired) {
card.awaitingIdentityPrompt = false;
card.busy = false;
Qt.callLater(card.focusResponse);
}
}
function onIsCompletedChanged() { answer.clear(); }
function onIsCancelledChanged() { answer.clear(); }
}
ColumnLayout {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 18
spacing: 12
RowLayout {
Layout.fillWidth: true
spacing: 12
Rectangle {
width: 38; height: 38; radius: 4
color: Theme.buttonBackground
Text { anchors.centerIn: parent; text: "󰌾"; color: Theme.accentForeground
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 23 }
}
Text { Layout.fillWidth: true; text: "Authentification requise"; color: Theme.foreground
font.pixelSize: 18; font.bold: true; wrapMode: Text.Wrap }
}
RowLayout {
Layout.fillWidth: true
spacing: 10
Image { Layout.preferredWidth: 24; Layout.preferredHeight: 24
source: card.flow && card.flow.iconName ? Quickshell.iconPath(card.flow.iconName) : ""
visible: status === Image.Ready; fillMode: Image.PreserveAspectFit }
Text { Layout.fillWidth: true; text: card.flow ? card.flow.message : ""
textFormat: Text.PlainText; color: Theme.foreground; wrapMode: Text.Wrap; font.pixelSize: 14 }
}
Text { Layout.fillWidth: true; text: "Compte d’authentification";
color: Theme.secondaryForeground; font.pixelSize: 12 }
Text { Layout.fillWidth: true; visible: !!card.flow && card.flow.identities.length === 1
text: card.flow && card.flow.selectedIdentity
? card.flow.selectedIdentity.displayName + " (" + card.flow.selectedIdentity.string + ")" : ""
textFormat: Text.PlainText; color: Theme.foreground; font.pixelSize: 14; elide: Text.ElideRight }
ComboBox {
id: identityChoice
Layout.fillWidth: true
visible: !!card.flow && card.flow.identities.length > 1
model: card.flow ? card.flow.identities.map(identity => ({ label: card.identityLabel(identity) })) : []
textRole: "label"
currentIndex: card.flow && card.flow.identities
? card.flow.identities.indexOf(card.flow.selectedIdentity) : -1
onActivated: index => card.selectIdentity(card.flow.identities[index])
background: Rectangle { radius: 4; color: Theme.inputBackground
border.color: identityChoice.activeFocus ? Theme.focus : Theme.inputBorder }
contentItem: Text { text: identityChoice.displayText; textFormat: Text.PlainText
color: Theme.foreground
verticalAlignment: Text.AlignVCenter; leftPadding: 10; elide: Text.ElideRight }
delegate: ItemDelegate {
width: identityChoice.width
text: modelData.label
contentItem: Text { text: parent.text; textFormat: Text.PlainText
color: Theme.foreground
verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight }
background: Rectangle { color: parent.highlighted ? Theme.buttonHover : Theme.popupBackground }
}
popup: Popup {
y: identityChoice.height
width: identityChoice.width
implicitHeight: Math.min(contentItem.implicitHeight, 240)
contentItem: ListView { clip: true; implicitHeight: contentHeight
model: identityChoice.popup.visible ? identityChoice.delegateModel : null
currentIndex: identityChoice.highlightedIndex }
background: Rectangle { radius: 4; color: Theme.popupBackground
border.color: Theme.outline }
}
}
Text { Layout.fillWidth: true
text: card.flow && card.flow.inputPrompt ? card.flow.inputPrompt : "En attente de la demande…"
textFormat: Text.PlainText; color: Theme.secondaryForeground; wrapMode: Text.Wrap; font.pixelSize: 12 }
TextField {
id: answer
objectName: "polkitResponse"
Layout.fillWidth: true
enabled: card.canEdit
visible: card.waiting
echoMode: card.flow && card.flow.responseVisible ? TextInput.Normal : TextInput.Password
color: Theme.foreground
placeholderTextColor: Theme.secondaryForeground
selectionColor: Theme.accent
selectedTextColor: Theme.onAccent
selectByMouse: true
placeholderText: card.flow && card.flow.responseVisible ? "Réponse" : "Réponse confidentielle"
Keys.onReturnPressed: event => { card.submitResponse(); event.accepted = true; }
Keys.onEnterPressed: event => { card.submitResponse(); event.accepted = true; }
background: Rectangle { radius: 4; color: Theme.inputBackground
border.color: answer.activeFocus ? Theme.focus : Theme.inputBorder }
}
Text { Layout.fillWidth: true
visible: !!card.flow && (!!card.flow.supplementaryMessage || card.flow.failed)
text: card.flow ? (card.flow.supplementaryMessage
|| (card.flow.failed ? "Authentification refusée. Réessayez." : "")) : ""
textFormat: Text.PlainText
color: card.flow && (card.flow.supplementaryMessage
? card.flow.supplementaryIsError : card.flow.failed)
? Theme.danger : Theme.secondaryForeground
wrapMode: Text.Wrap; font.pixelSize: 12 }
Text { Layout.fillWidth: true; visible: !card.waiting
text: card.flow && card.flow.isCompleted ? "Demande terminée" : "En attente de l’agent d’authentification…"
color: Theme.secondaryForeground; font.pixelSize: 12 }
Button {
id: detailButton
text: card.detailsOpen ? "Masquer l’identifiant de l’action" : "Détails de l’action"
flat: true
onClicked: card.detailsOpen = !card.detailsOpen
contentItem: Text { text: detailButton.text; color: Theme.accentForeground
font.pixelSize: 12; verticalAlignment: Text.AlignVCenter }
}
Text { Layout.fillWidth: true; visible: card.detailsOpen
text: card.flow ? card.flow.actionId : ""; textFormat: Text.PlainText
color: Theme.secondaryForeground; wrapMode: Text.WrapAnywhere; font.pixelSize: 11 }
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button { id: cancelButton; text: "Annuler"; onClicked: card.cancelRequest()
background: Rectangle { radius: 4; color: cancelButton.hovered ? Theme.buttonHover : Theme.buttonBackground
border.color: cancelButton.activeFocus ? Theme.focus : Theme.outline }
contentItem: Text { text: cancelButton.text; color: Theme.foreground
horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } }
Button { id: submitButton; text: "Authentifier"; enabled: card.canSubmit
onClicked: card.submitResponse()
background: Rectangle { radius: 4; color: Theme.accent
border.color: submitButton.activeFocus ? Theme.foreground : Theme.accent }
contentItem: Text { text: submitButton.text; color: Theme.onAccent
horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } }
}
}
Shortcut { sequence: "Escape"; context: Qt.WindowShortcut; onActivated: card.cancelRequest() }
}

View file

@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import "../theme"
PanelWindow {
id: window
required property var requestFlow
required property var hostScreen
readonly property var flow: requestFlow
readonly property Item card: form
screen: hostScreen
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.namespace: "labfy-polkit-authentication"
color: "transparent"
// WHY: Sway gives keyboard focus to an exclusive layer surface. The
// surface exists only for this flow and never dismisses on outside click.
Rectangle {
id: frame
anchors.centerIn: parent
width: Math.max(0, Math.min(480, parent.width - 32))
height: Math.max(0, Math.min(form.implicitHeight, parent.height - 32))
radius: 4
color: Theme.popupBackground
border.color: Theme.outline
border.width: 1
clip: true
ScrollView {
anchors.fill: parent
contentWidth: availableWidth
PolkitCard {
id: form
width: parent.width
height: implicitHeight
flow: window.flow
}
}
}
Timer { interval: 120; running: true; onTriggered: form.focusResponse() }
Component.onDestruction: form.clearResponse()
}

View file

@ -0,0 +1,152 @@
# Agent graphique Polkit V1
État au 9 octobre 2026 : l'agent QuickShell est actif. Une demande réelle a
réussi, puis une autorisation `pkexec` a permis de retirer le paquet
`polkit-gnome`, désormais inutile. `faillock` est vide après ces deux
authentifications. Les fichiers actifs avant la reprise sont sauvegardés dans
`/home/fy59/.local/state/labfy-polkit-v2-rollback-20261009/` ; ceux du premier
essai restent dans `/home/fy59/.local/state/labfy-polkit-investigation-20261009/`.
Sway ne lance plus `polkit-gnome-authentication-agent-1`. La session graphique
est SwayFX sur Wayland, et `quickshell-labfy-sway.service` lance QuickShell
sous l'utilisateur ordinaire. Son `shell.qml` possède l'unique `PolkitAgent` ;
le module natif `Quickshell.Services.Polkit` garde la file des demandes.
`PolkitDialog.qml` est créé uniquement pour le `flow` courant, sur la sortie du
workspace focalisé au début de la demande. Une sortie débranchée déclenche un
repli vers une sortie disponible. La surface layer-shell exclusive est détruite
après la demande ; un clic extérieur ne fait rien. Les panneaux de barre se
ferment et les captures automatiques Overview sont suspendues pendant la demande.
La palette vient de `Theme.qml`, elle-même alimentée par l'état effectif
`AppearanceController` et `theme/catppuccin.json`. Le dialogue suit donc les
changements de thème QuickShell. La synchronisation avec les réglages GTK/Qt
reste celle du contrôleur d'apparence existant ; aucun thème GTK supplémentaire
n'est imposé par l'agent.
`AuthFlow` fournit le message réel, l'icône, l'identifiant technique, les
identités admises, l'invite, `responseVisible`, le message complémentaire et son
état d'erreur. Une seule identité est affichée comme texte ; plusieurs passent
par un sélecteur dont les valeurs proviennent exclusivement de Polkit.
`submit()` reçoit directement le contenu du champ du flow courant lorsque
`isResponseRequired` est vrai. La réponse est effacée après soumission, échec,
changement d'identité, annulation, disparition ou remplacement du flow.
Une réponse secrète est masquée selon `responseVisible`. Le champ ne passe ni
par argv, ni par fichier, ni par IPC, ni par journal. Ceci limite les copies et
la durée de vie de la chaîne QML sans promettre un effacement physique de la
mémoire Qt. Le collage manuel reste disponible. Le succès est décidé par le
backend Polkit, jamais par l'appui sur Entrée.
Limite de l'API 0.3.1 : un changement d'identité vers une conversation ayant
exactement le même texte d'invite peut ne produire aucun signal de changement
de `inputPrompt` ni de `isResponseRequired`. Le dialogue garde alors la
soumission bloquée pendant une seconde, puis réactive le champ seulement si
le même flow, la nouvelle identité et `isResponseRequired` sont toujours
présents. Cette temporisation reste locale à la présentation et ne crée pas
de deuxième conversation ni de file de réponses. Une invite secrète vide ne
peut plus être soumise accidentellement : le champ reste modifiable, mais
Entrée et le bouton ne transmettent rien tant qu'il est vide.
L'API du paquet QuickShell 0.3.1 installé a été vérifiée dans
`/usr/lib/qt6/qml/Quickshell/Services/Polkit/quickshell-service-polkit.qmltypes`
et dans les sources de son paquet debug. `PolkitAgent` expose `isRegistered`,
`isActive`, `flow`, `authenticationRequestStarted`. `AuthFlow` expose les
propriétés ci-dessus, `submit()` et `cancelAuthenticationRequest()`. Le paquet
construit le sujet d'enregistrement depuis le PID QuickShell et annule la
requête en cours si l'agent est détruit. Si QuickShell s'arrête pendant une
demande, Polkit ne l'autorise pas par défaut : le demandeur reçoit une
annulation ou une erreur. Un redémarrage hors demande doit rétablir
`isRegistered=true`.
## Vérifications et démonstration
`PolkitVisualFixture.qml` est un shell de test autonome : il n'instancie
**aucun** `PolkitAgent`. Son IPC `polkitFixture` n'existe pas dans `shell.qml`
et ne peut pas autoriser une action Polkit. Il sert à vérifier message court ou
long, icône absente, une ou plusieurs identités, réponse visible ou masquée,
erreur et nouvelle invite, annulation et double soumission. La capture vide
[de démonstration](../../../../../docs/assets/screenshots/quickshell-polkit-demo-empty.png)
provient seulement de cette fixture, sans secret réel.
```sh
qmllint ~/.config/quickshell/labfy-sway/{shell.qml,Bar.qml,polkit/*.qml}
qs -p ~/.config/quickshell/labfy-sway/PolkitVisualFixture.qml
qs ipc -p ~/.config/quickshell/labfy-sway/PolkitVisualFixture.qml call polkitFixture checks
qs -c labfy-sway ipc call polkitUi state
```
Pour vérifier une demande réelle sans opération privilégiée, `pkcheck` peut
contrôler `org.freedesktop.timedate1.set-timezone` avec
`--process PID,START_TIME,UID --allow-user-interaction`, le PID étant celui de
Sway dans la session graphique. Ne pas ajouter `--enable-internal-agent`.
Un résultat autorisé immédiatement ne valide pas le dialogue. Aucun véritable
mot de passe n'est utilisé dans les tests automatisés ; une authentification
réelle a été validée séparément dans le dialogue local lors de la reprise.
### Premier essai
Lors de la bascule du 9 octobre 2026, la fixture a confirmé soumission unique,
erreur puis nouvelle invite, changement d'identité, disparition du flow,
deuxième demande et annulation, avec toutes les assertions à `true`. Le cas
des deux identités ayant la même invite a maintenu la soumission bloquée puis
l'a réactivée après une seconde. Les 22 tests `unittest` ciblés sur le pont
d'inhibition, l'annotation de capture et les mises à jour ont passé ;
`qmllint`, `git diff --check` et la simulation Stow ont passé. Le contrôle
`sway -C` a retourné 0 avec un avertissement EGL d'énumération GPU.
Le nouvel agent a répondu `registered=true` après l'arrêt ciblé de l'ancien.
`pkcheck` sur le sujet graphique complet `21858,320156,1000` a produit
`active=true`, `dialog=true` sur `eDP-1`. La disparition du demandeur a fermé
la surface (`active=false`, `dialog=false`). Une seconde demande a ouvert le
dialogue puis a été rejetée avec le code `3` lors d'un rechargement du shell ;
ce rechargement n'a donc accordé aucune autorisation. Un rechargement hors
demande a conservé `registered=true` avec un seul processus QuickShell.
Pendant les éditions QML suivantes, un autre rechargement hors demande a
rencontré l'erreur Wayland `xdg_popup has no parent` ; le service a redémarré
QuickShell et l'enregistrement est redevenu `true`, sans deuxième agent.
Cette erreur de popup lors d'un rechargement avec des panneaux existants reste
une limite observée de la session, sans autorisation implicite.
L'annulation par le bouton local reste à confirmer par une interaction
utilisateur hors essai d'authentification. Annuler une conversation PAM peut
consommer une tentative et ne doit pas être répété pour tester la présentation.
### Incident de verrouillage et reprise
Le premier essai a provoqué à 06:55–06:56 deux conversations Polkit échouées
sans mot de passe exploitable et une réponse refusée. `pam_faillock` a compté
trois échecs et bloqué temporairement Swaylock et `login`. Le mot de passe
n'avait pas changé. Le verrouillage a expiré et une session TTY2 a ensuite
réussi ; le redémarrage a effacé le compteur volatil. Aucune permission PAM ni
règle `sudoers` n'a été modifiée.
La reprise a utilisé d'abord `PolkitVisualFixture.qml`, sans `PolkitAgent`.
Son IPC a confirmé notamment le blocage d'une réponse secrète vide, une seule
soumission, le remplacement du flow et l'effacement du champ. Avant la demande
réelle, `faillock --user fy59` était vide et QuickShell indiquait
`registered=true`. L'utilisateur a saisi le mot de passe une seule fois dans
la fenêtre locale pour un `pkcheck` sur
`org.freedesktop.timedate1.set-timezone` ; la commande a retourné 0 sans
changer le fuseau. Le dialogue s'est fermé et `faillock` est resté vide.
Une autre demande Polkit, initiée par `pkexec` pour retirer uniquement le paquet
`polkit-gnome`, a également réussi. Aucun secret n'a été transmis à cette
conversation, à un argument de commande ou à un journal.
## Retour arrière ciblé
Le paquet GNOME a été retiré. Pour revenir à cet agent, le réinstaller depuis
un TTY (`sudo pacman -S polkit-gnome`) si l'agent QuickShell ne fonctionne pas.
Hors demande d'authentification, la copie persistante créée avant la reprise
retire ensuite l'agent QuickShell et rétablit l'autostart GNOME :
```sh
base=/home/fy59/.dotfiles
backup=/home/fy59/.local/state/labfy-polkit-v2-rollback-20261009
cp "$backup/shell.qml" "$base/quickshell/.config/quickshell/labfy-sway/shell.qml"
cp "$backup/Bar.qml" "$base/quickshell/.config/quickshell/labfy-sway/Bar.qml"
cp "$backup/WorkspaceOverview.qml" "$base/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml"
cp "$backup/autostart" "$base/sway/.config/sway/autostart"
systemctl --user restart quickshell-labfy-sway.service
swaymsg exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1
```
Ne pas lancer ce retour arrière pendant une demande active : interrompre une
conversation PAM peut consommer une tentative. Ne pas arrêter `polkitd`.

View file

@ -3,12 +3,98 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.I3
import Quickshell.Services.Polkit
import "notifications"
import "idlebridge"
import "theme"
import "polkit"
ShellRoot {
id: shell
// CONTRACT: one PolkitAgent owns the native request queue for the entire
// user session. A dialog is constructed only for its current flow.
PolkitAgent { id: polkitAgent }
property var polkitScreen: null
readonly property bool authenticationActive: polkitAgent.flow !== null
signal authenticationOpening()
function choosePolkitScreen(output) {
const screens = Quickshell.screens;
polkitScreen = screens.find(screen => screen.name === output)
|| (screens.length ? screens[0] : null);
}
function beginPolkitPresentation() {
polkitScreen = null;
if (!polkitAgent.flow) {
polkitOutput.running = false;
return;
}
authenticationOpening();
// WHY: the focused Sway workspace identifies the output at request
// start. Later focus changes do not move the authentication surface.
if (!polkitOutput.running) polkitOutput.running = true;
else choosePolkitScreen("");
}
Connections {
target: polkitAgent
function onFlowChanged() { shell.beginPolkitPresentation(); }
function onAuthenticationRequestStarted() {
if (!polkitOutput.running && !shell.polkitScreen)
shell.beginPolkitPresentation();
}
}
Timer {
// WHY: a missing or disconnected Sway IPC must not leave an active
// Polkit request with no visible way to cancel it.
interval: 500
running: !!polkitAgent.flow && !shell.polkitScreen
onTriggered: {
shell.choosePolkitScreen("");
polkitOutput.running = false;
}
}
Process {
id: polkitOutput
command: ["swaymsg", "-t", "get_workspaces", "-r"]
stdout: StdioCollector { id: polkitOutputData; waitForEnd: true }
onExited: (code, status) => {
if (!polkitAgent.flow || shell.polkitScreen) return;
let output = "";
if (code === 0) {
try {
const focused = JSON.parse(polkitOutputData.text).find(item => item.focused);
output = focused ? focused.output : "";
} catch (_) {}
}
shell.choosePolkitScreen(output);
}
}
// INVARIANT: a vanished output moves the same flow to a surviving screen;
// it never creates a second PolkitAgent or another request queue.
Connections {
target: Quickshell
function onScreensChanged() {
if (polkitAgent.flow && Quickshell.screens.indexOf(shell.polkitScreen) < 0)
shell.choosePolkitScreen("");
}
}
LazyLoader {
id: polkitDialog
active: !!polkitAgent.flow && !!shell.polkitScreen
PolkitDialog { requestFlow: polkitAgent.flow; hostScreen: shell.polkitScreen }
}
IpcHandler {
target: "polkitUi"
function state(): string {
return JSON.stringify({ registered: polkitAgent.isRegistered,
active: polkitAgent.isActive, dialog: polkitDialog.active,
screen: shell.polkitScreen ? shell.polkitScreen.name : "",
queryRunning: polkitOutput.running });
}
}
Component.onCompleted: {
modeSnapshot.running = true;
if (polkitAgent.flow) beginPolkitPresentation();
}
property bool resizeMode: false
// CONTRACT: une seule sélection pour toutes les barres. L'inhibition est
// volontairement éphémère : un redémarrage de QuickShell la libère.
@ -51,6 +137,7 @@ ShellRoot {
property string applicationsOutput: ""
signal applicationsOpening(string output)
function toggleApplications(output) {
if (authenticationActive) return;
if (applicationsOutput === output) { applicationsOutput = ""; return; }
applicationsOpening(output);
applicationsOutput = output;
@ -81,7 +168,6 @@ ShellRoot {
catch (error) { console.warn("État mode Sway invalide", error); }
}
}
Component.onCompleted: modeSnapshot.running = true
Connections {
target: I3
function onConnected() { if (!modeSnapshot.running) modeSnapshot.running = true; }
@ -115,6 +201,7 @@ ShellRoot {
Bar {
applicationCoordinator: shell
authenticationActive: shell.authenticationActive
keepAwakeController: shell
resizeMode: shell.resizeMode
notificationService: rootNotificationService

View file

@ -9,9 +9,8 @@
# Listener IPC démarré une fois par session ; exec ne se rejoue pas au reload.
exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py --opacity $inactive_opacity
# CONTRACT: l'agent Polkit affiche l'authentification standard de timedated.
# exec n'est lancé qu'à l'ouverture de session, pas à chaque reload Sway.
exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1
# CONTRACT: QuickShell possède l'unique agent graphique Polkit de la session.
# Aucun deuxième agent n'est lancé par Sway à la prochaine connexion.
# =================================================
# Règles d'assignation automatique