feat(quickshell): replace swaylock with native lockscreen

This commit is contained in:
fy59 2026-10-09 20:22:41 +02:00
parent 02236cfdeb
commit 9aa3bc8353
35 changed files with 1375 additions and 17 deletions

View file

@ -180,7 +180,6 @@ strace
sudo
swaybg
swayidle
swaylock
swaync
system-config-printer
tcpdump

View file

@ -120,7 +120,7 @@ cd "$HOME/.dotfiles"
packages=(atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock \
hyprwhspr icons kitty mako nvim opencode portal qt5ct qt6ct \
quickshell starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi \
quickshell starship sway systemd themes tmux uwsm wallpapers wofi yazi \
ytmusic-tui zsh)
stow --no --verbose=2 --target="$HOME" "${packages[@]}"

133
bin/.local/bin/labfy-lock Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Lance le verrou QuickShell dédié et attend la confirmation du compositeur."""
import fcntl
import json
import os
import pwd
from pathlib import Path
import secrets
import stat
import subprocess
import sys
import time
TIMEOUT = 8.0
CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "quickshell/labfy-lock"
def live(pid):
try:
os.kill(pid, 0)
# Orphaned lockers may stay as zombies until the parent reaps them.
stat = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1]
return not stat.startswith("Z ")
except (OSError, ValueError):
return False
def start_ticks(pid):
try:
# /proc stat comm is parenthesized and may itself contain spaces.
return int(Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()[19])
except (OSError, ValueError, IndexError):
return None
def avatar_path(username):
path = Path("/var/lib/AccountsService/icons") / username
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or not 0 < info.st_size <= 2 * 1024 * 1024:
return ""
with path.open("rb") as stream:
header = stream.read(24)
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
return ""
width = int.from_bytes(header[16:20], "big")
height = int.from_bytes(header[20:24], "big")
return str(path) if 0 < width <= 512 and 0 < height <= 512 else ""
except OSError:
return ""
def state(pid):
try:
result = subprocess.run(
["quickshell", "ipc", "--pid", str(pid), "call", "lock", "state"],
capture_output=True, text=True, timeout=0.8, check=False,
)
if result.returncode == 0:
return result.stdout.strip().strip('"')
except (OSError, subprocess.TimeoutExpired):
pass
return ""
def main():
if not CONFIG.joinpath("shell.qml").is_file():
print("Configuration labfy-lock absente.", file=sys.stderr)
return 1
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", ""))
if not runtime.is_dir() or runtime.stat().st_uid != os.getuid():
print("Répertoire runtime privé indisponible.", file=sys.stderr)
return 1
lockfile = runtime / "labfy-lock.launch"
capture_guard = runtime / "labfy-lock.capture-guard"
fd = os.open(lockfile, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "r+", encoding="utf-8") as guard:
fcntl.flock(guard, fcntl.LOCK_EX)
try:
record = json.load(guard)
except (ValueError, OSError):
record = {}
pid = record.get("pid")
generation = record.get("generation")
ticks = record.get("start_ticks")
if (not isinstance(pid, int) or pid <= 0 or not isinstance(generation, str)
or not isinstance(ticks, int) or not live(pid) or start_ticks(pid) != ticks):
generation = secrets.token_hex(16)
# WHY: Overview must stop scheduled captures before any lock
# surface, including the authentication field, can be mapped.
capture_guard.write_text("active\n")
capture_guard.chmod(0o600)
account = pwd.getpwuid(os.getuid())
env = dict(os.environ, LABFY_LOCK_GENERATION=generation,
LABFY_LOCK_USER=account.pw_name,
LABFY_LOCK_DISPLAY=account.pw_gecos.split(",", 1)[0] or account.pw_name,
LABFY_LOCK_AVATAR=avatar_path(account.pw_name))
# CONTRACT: the locker survives the wrapper; no secret goes in argv,
# environment, output, or a persistent file.
process = subprocess.Popen(
["quickshell", "--path", str(CONFIG)], env=env,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
close_fds=True,
)
pid = process.pid
guard.seek(0)
guard.truncate()
json.dump({"pid": pid, "generation": generation,
"start_ticks": start_ticks(pid)}, guard)
guard.flush()
os.fsync(guard.fileno())
deadline = time.monotonic() + TIMEOUT
expected = generation + ":secure"
while time.monotonic() < deadline:
if not live(pid):
print("Le verrouilleur s’est arrêté avant confirmation.", file=sys.stderr)
return 1
if state(pid) == expected:
return 0
time.sleep(0.08)
print("Verrouillage non confirmé par le compositeur.", file=sys.stderr)
return 1
except OSError as error:
print(f"Démarrage du verrou impossible : {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Préférence média du verrouilleur, à lancer depuis le bureau déverrouillé."""
import json
import os
from pathlib import Path
import sys
import tempfile
def main():
if len(sys.argv) != 3 or sys.argv[1] != "media" or sys.argv[2] not in ("hidden", "generic", "metadata"):
print("Usage : labfy-lock-settings media hidden|generic|metadata", file=sys.stderr)
return 2
directory = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "quickshell/labfy-lock"
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
target = directory / "preferences.json"
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=directory,
prefix=".preferences-", delete=False) as stream:
temporary = Path(stream.name)
os.fchmod(stream.fileno(), 0o600)
json.dump({"version": 1, "media": sys.argv[2]}, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
return 0
if __name__ == "__main__":
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -58,6 +58,6 @@ leurs règles existantes restent en vigueur.
installer de paquet. `cliphist-text.service`, `cliphist-image.service`,
`swayidle.service`, `wlsunset.service`, les agents SSH et keyring restent des
unités utilisateur distinctes. Les raccourcis Sway utilisent notamment
`pactl`, `brightnessctl`, `grim`, `slurp`, `wl-copy`, `wofi`, `swaylock` et
`pactl`, `brightnessctl`, `grim`, `slurp`, `wl-copy`, `wofi`, `labfy-lock` et
`yazi`. Le détail de QuickShell et des dépendances figure dans son
[README](../quickshell/.config/quickshell/labfy-sway/README.md).

View file

@ -71,7 +71,7 @@ Les commandes suivantes sont destinées à Bash :
packages=(
atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock
hyprwhspr icons kitty mako nvim opencode portal qt5ct qt6ct
quickshell starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi
quickshell starship sway systemd themes tmux uwsm wallpapers wofi yazi
ytmusic-tui zsh
)
@ -93,6 +93,10 @@ changements de fuseau effectués par `timedatectl`. Le service utilisateur
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`.
Avant le premier verrouillage par `labfy-lock`, installer la politique
`system/pam.d/labfy-lock` dans `/etc/pam.d/labfy-lock` avec les droits root,
selon [la procédure PAM](../system/pam.d/README.md). Le package Stow
historique `swaylock` n'est pas requis.
## 6. Effectuer le dry-run

View file

@ -42,6 +42,15 @@ socket privée et ne modifie pas `swayidle.service`. Voir
[portal/README.md](../portal/README.md) pour les cookies, la récupération
après déconnexion et le retour arrière.
`swayidle.service` utilise `/home/fy59/.local/bin/labfy-lock` pour le délai
de verrouillage de 300 s et `before-sleep`, après la pause média. Le wrapper
attend la confirmation `secure` avant de rendre la main à `swayidle -w`.
Les autres délais et commandes d'inactivité sont conservés. La politique
PAM dédiée provient de `system/pam.d/labfy-lock` et s'installe en tant que
`/etc/pam.d/labfy-lock` avant un nouveau déploiement du verrou. La procédure
historique de retour à `swaylock` est documentée dans
[labfy-lock/README.md](../quickshell/.config/quickshell/labfy-lock/README.md).
## Supports amovibles USB/SD et MTP
`labfy-removable-media.service` appartient au package Stow `systemd` et son

View file

@ -85,7 +85,7 @@ MIME et ne remplace pas les portails d'autres interfaces.
## Vidéo Firefox et inhibition de l'inactivité (diagnostic du 9 octobre 2026)
Dans cette session SwayFX, `swayidle.service` lance `swaylock` après 300 s,
Lors de ce diagnostic, `swayidle.service` lançait `swaylock` après 300 s,
éteint les sorties après 360 s et met les lecteurs en pause après 420 s. Le
verrouillage manuel et la protection `before-sleep` sont indépendants. Le
symptôme historique n'a pas été observé à l'instant exact du verrouillage ;

View file

@ -0,0 +1,50 @@
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: appearance
// CONTRACT: this reads only the effective state written by the existing
// appearance controller. It never runs a second controller or writes it.
readonly property string path: (Quickshell.env("XDG_STATE_HOME") || Quickshell.env("HOME") + "/.local/state")
+ "/labfy-appearance/effective.json"
property FileView stateFile: FileView { path: appearance.path; blockLoading: false; printErrors: false }
property FileView preferenceFile: FileView {
path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config")
+ "/quickshell/labfy-lock/preferences.json"
printErrors: false
}
property FileView paletteFile: FileView {
path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config")
+ "/quickshell/labfy-sway/theme/catppuccin.json"
blockLoading: false
printErrors: false
}
readonly property var state: {
try {
const value = JSON.parse(stateFile.text());
return value && typeof value === "object" ? value : {};
} catch (_) { return {}; }
}
readonly property string wallpaper: typeof state.effectiveWallpaper === "string"
&& state.effectiveWallpaper.startsWith("/") ? "file://" + state.effectiveWallpaper : ""
readonly property string mediaVisibility: {
// CONTRACT: an explicit privacy preference wins; without one, show
// available local artwork and metadata as requested by the V3 layout.
try {
const value = JSON.parse(preferenceFile.text());
return value.version === 1 && ["hidden", "generic", "metadata"].includes(value.media)
? value.media : "metadata";
} catch (_) { return "metadata"; }
}
readonly property var palette: {
try {
const palettes = JSON.parse(paletteFile.text());
return palettes[state.effectiveFlavor] || palettes.mocha || {};
} catch (_) { return {}; }
}
readonly property color base: palette.base || "#1e1e2e"
readonly property color surface: palette.surface0 || "#313244"
readonly property color foreground: palette.text || "#ffffff"
readonly property color accent: palette[state.effectiveAccent] || "#b4befe"
}

View file

@ -0,0 +1,6 @@
// INVARIANT: only an explicit success for the still-current, non-aborted
// conversation while compositor secure may release the session lock.
function mayUnlock(active, aborting, secure, result, success) {
return active === true && aborting === false && secure === true
&& result === success;
}

View file

@ -0,0 +1,281 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Services.UPower
Item {
id: content
required property var controller
required property var hostSurface
required property var player
required property var notificationData
required property var battery
property bool preview: false
property var previewState: ({})
property alias field: answer
Appearance { id: appearance }
SystemClock { id: clock; precision: SystemClock.Minutes }
readonly property string mediaMode: preview && previewState.mediaMode
? previewState.mediaMode : appearance.mediaVisibility
readonly property bool lightTheme: appearance.state.effectiveFlavor === "latte"
readonly property bool mediaShown: !!player && mediaMode !== "hidden"
readonly property bool notificationsShown: !!notificationData && notificationData.total > 0
readonly property bool compact: height < 680 || width < 900
readonly property int cardWidth: Math.min(compact ? 340 : 380, width - 32)
// CONTRACT: artwork remains local-only. Long but bounded file URIs from
// MPRIS caches must reach Image; Image.status decides whether they work.
readonly property string coverUri: mediaMode === "metadata" && player
&& typeof player.trackArtUrl === "string"
&& player.trackArtUrl.startsWith("file://") && player.trackArtUrl.length <= 4096
? player.trackArtUrl : ""
readonly property bool coverUsable: coverUri && coverImage.status !== Image.Error
readonly property string dateText: {
const value = Qt.locale("fr_FR").toString(clock.date, "dddd d MMMM");
return value.charAt(0).toUpperCase() + value.slice(1);
}
Rectangle { anchors.fill: parent; color: appearance.base }
// CONTRACT: only the existing effective wallpaper is rendered; a failed
// load retains an opaque palette background, never a desktop capture.
Image {
anchors.fill: parent
source: content.preview ? "" : appearance.wallpaper
asynchronous: true
fillMode: Image.PreserveAspectCrop
visible: status === Image.Ready
sourceSize.width: Math.min(content.width * 1.5, 2560)
sourceSize.height: Math.min(content.height * 1.5, 1600)
}
Rectangle { anchors.fill: parent; color: content.lightTheme ? "#b2eff1f5" : "#991e1e2e" }
// INVARIANT: one bounded central column preserves the requested reading
// order on every output; optional cards collapse without moving auth into
// a corner. The reserved feedback area absorbs PAM and Caps Lock changes.
Column {
id: stack
width: content.cardWidth
anchors.horizontalCenter: parent.horizontalCenter
y: Math.max(36, (content.height - height) / 2)
spacing: content.compact ? 4 : 6
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: content.preview && content.previewState.time ? content.previewState.time : Qt.formatTime(clock.date, "HH:mm")
color: appearance.foreground
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: content.compact ? 62 : 76
font.weight: Font.Light
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: content.preview && content.previewState.date ? content.previewState.date : content.dateText
color: appearance.foreground
opacity: 0.82
font.pixelSize: content.compact ? 15 : 18
}
Item { width: 1; height: content.compact ? 5 : 9 }
Item {
width: parent.width; height: content.compact ? 46 : 58
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: parent.height; height: parent.height; radius: 4
color: appearance.surface
border.color: appearance.accent
clip: true
Image {
id: avatarImage
anchors.fill: parent
asynchronous: true
source: !content.preview && controller.avatar ? "file://" + controller.avatar : ""
fillMode: Image.PreserveAspectCrop
sourceSize.width: 128; sourceSize.height: 128
visible: status === Image.Ready
}
Text {
anchors.centerIn: parent
text: ""; color: appearance.foreground
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 29
visible: !avatarImage.visible
}
}
}
Text {
width: parent.width; horizontalAlignment: Text.AlignHCenter
text: controller.displayName; color: appearance.foreground
font.pixelSize: content.compact ? 16 : 18; elide: Text.ElideRight
}
Text {
width: parent.width; height: visible ? 15 : 0
horizontalAlignment: Text.AlignHCenter
visible: controller.displayName !== controller.loginUser
text: controller.loginUser; color: appearance.foreground; opacity: 0.72
font.pixelSize: 11; elide: Text.ElideRight
}
Text {
width: parent.width; height: visible ? 15 : 0
horizontalAlignment: Text.AlignHCenter
visible: !!content.battery && content.battery.isPresent
text: visible ? Math.round(content.battery.percentage * 100) + " % · "
+ (content.battery.state === UPowerDeviceState.Charging ? "En charge"
: content.battery.state === UPowerDeviceState.Discharging ? "Sur batterie" : "Sur secteur") : ""
color: appearance.foreground; opacity: 0.68; font.pixelSize: 11
}
Item { width: 1; height: content.compact ? 1 : 4 }
Item {
id: auth
width: parent.width; height: 118
Text {
width: parent.width; height: 22
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
text: controller.attempt && controller.activeSurface === hostSurface
&& hostSurface.secure && controller.pamResponseRequired
? (controller.prompt || "Réponse attendue") : "Déverrouillage"
color: appearance.foreground; font.pixelSize: 13
elide: Text.ElideRight
}
TextField {
id: answer
y: 25; width: parent.width; height: 42
color: appearance.foreground
horizontalAlignment: TextInput.AlignHCenter
background: Rectangle {
radius: 4; color: appearance.surface
border.color: answer.activeFocus ? appearance.accent : "#777783"
border.width: 1
}
echoMode: controller.responseVisible ? TextInput.Normal : TextInput.Password
enabled: !content.preview && controller.activeSurface === hostSurface
&& hostSurface.secure && !controller.aborting
placeholderText: content.preview ? "Saisie désactivée dans l’aperçu"
: controller.attempt ? "Réponse" : "Appuyer sur Entrée pour commencer"
onAccepted: controller.submit(text)
Keys.onEscapePressed: controller.cancel()
Keys.onPressed: event => {
if (event.key === Qt.Key_CapsLock) {
controller.capsObserved = true;
controller.capsOn = !controller.capsOn;
}
}
Component.onCompleted: if (!content.preview && controller.activeSurface === hostSurface) forceActiveFocus()
}
// CONTRACT: this fixed area contains keyboard, Caps Lock and PAM
// feedback; their appearance never moves the password field.
Text {
y: 70; width: parent.width; height: 17
text: (controller.keyboardLayout || "Disposition inconnue")
+ (controller.capsObserved && controller.capsOn ? " · Verr. maj. activé" : "")
color: appearance.foreground; opacity: 0.76; font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter; elide: Text.ElideRight
}
Text {
y: 88; width: parent.width; height: 30
text: controller.feedback
color: controller.feedbackIsError
? (appearance.palette.red || "#f38ba8") : appearance.foreground
opacity: controller.feedbackIsError ? 1 : 0.76
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignTop
wrapMode: Text.WordWrap; elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
enabled: !content.preview && controller.activeSurface !== hostSurface
onClicked: controller.chooseSurface(hostSurface)
}
}
// CONTRACT: media keeps its established visibility policy. Valid local
// artwork gets a stable visible slot; failed loads use the small fallback.
Rectangle {
id: mediaCard
width: parent.width; height: visible ? (content.compact ? 82 : 98) : 0
radius: 4; visible: content.mediaShown
color: content.lightTheme ? "#dceff1f5" : "#d41e1e2e"
border.color: appearance.surface; border.width: 1
Rectangle {
x: 10; anchors.verticalCenter: parent.verticalCenter
width: content.coverUsable ? 72 : 32
height: width; radius: 4
color: appearance.surface; clip: true
Image {
id: coverImage
anchors.fill: parent
source: content.coverUri
asynchronous: true; fillMode: Image.PreserveAspectCrop
sourceSize.width: 144; sourceSize.height: 144
}
Text {
visible: !content.coverUsable
anchors.centerIn: parent; text: ""
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 20
color: appearance.foreground; opacity: 0.55
}
}
Column {
x: content.coverUsable ? 94 : 52
y: content.compact ? 8 : 10
width: parent.width - x - 10; spacing: content.compact ? 2 : 5
Text {
width: parent.width; elide: Text.ElideRight
text: content.mediaMode === "metadata" && content.player
? content.player.trackTitle : "Lecture média"
color: appearance.foreground; font.pixelSize: 13
}
Text {
width: parent.width; elide: Text.ElideRight
text: content.mediaMode === "metadata" && content.player
? content.player.trackArtist : ""
color: appearance.foreground; opacity: 0.72; font.pixelSize: 12
}
Row {
spacing: 8
Repeater {
model: [
{ label: "⏮", ability: "canGoPrevious", action: "previous" },
{ label: content.player && content.player.isPlaying ? "⏸" : "▶",
ability: "canTogglePlaying", action: "togglePlaying" },
{ label: "⏭", ability: "canGoNext", action: "next" }
]
Rectangle {
required property var modelData
readonly property bool allowed: content.player
&& content.player[modelData.ability] === true
visible: allowed
width: visible ? 34 : 0; height: 26; radius: 4
color: appearance.surface
Text { anchors.centerIn: parent; text: parent.modelData.label; color: appearance.foreground }
MouseArea {
anchors.fill: parent; enabled: parent.allowed && !content.preview
onClicked: if (content.player && parent.allowed)
content.player[parent.modelData.action]()
}
}
}
}
}
Rectangle {
x: 10; y: parent.height - 5; width: parent.width - 20; height: 2
visible: content.mediaMode === "metadata" && content.player
&& content.player.positionSupported && content.player.length > 0
color: "#45475a"
Rectangle {
height: parent.height
width: content.player && content.player.length > 0
? parent.width * Math.max(0, Math.min(1, content.player.position / content.player.length)) : 0
color: appearance.accent
}
}
}
// INVARIANT: notification projection contains public app counters only;
// no summary, body or history is introduced by this visual card.
Rectangle {
width: parent.width; height: visible ? (content.compact ? 83 : 100) : 0
radius: 4; visible: content.notificationsShown
color: content.lightTheme ? "#dceff1f5" : "#d41e1e2e"
border.color: appearance.surface; border.width: 1
NotificationCardBody { anchors.fill: parent; projection: content.notificationData; foreground: appearance.foreground }
}
}
}

View file

@ -0,0 +1,55 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.UPower
import Quickshell.Services.Mpris
WlSessionLockSurface {
id: surface
required property var controller
required property bool secure
property alias field: content.field
color: "#1e1e2e"
readonly property var battery: UPower.displayDevice
readonly property string notificationPath: (Quickshell.env("XDG_RUNTIME_DIR") || "")
+ "/labfy-lock-notifications.json"
FileView { id: notificationFile; path: surface.notificationPath; printErrors: false }
readonly property var notificationData: {
try {
const raw = notificationFile.text();
if (raw.length > 4096) return null;
const data = JSON.parse(raw);
if (data.version !== 1 || !Number.isSafeInteger(data.total) || data.total < 0
|| data.total > 200 || !Array.isArray(data.apps)) return null;
return { total: data.total, apps: data.apps.slice(0, 4).filter(item => item
&& typeof item.name === "string" && /^[^\r\n\t]{1,48}$/.test(item.name)
&& Number.isSafeInteger(item.count) && item.count >= 0 && item.count <= 200
&& typeof item.icon === "string" && /^[A-Za-z0-9_.-]{0,64}$/.test(item.icon)) };
} catch (_) { return null; }
}
readonly property var player: {
const items = Mpris.players.values.filter(item => item
&& item.dbusName !== "org.mpris.MediaPlayer2.playerctld"
&& item.trackTitle && item.trackTitle.trim().length);
items.sort((a, b) => a.dbusName.localeCompare(b.dbusName));
return items.find(item => item.playbackState === MprisPlaybackState.Playing)
|| items.find(item => item.playbackState === MprisPlaybackState.Paused) || null;
}
Component.onCompleted: controller.addSurface(surface)
Component.onDestruction: controller.removeSurface(surface)
// CONTRACT: the shared visual component owns presentation and input focus;
// the lock, PAM conversation, and privacy-filtered data stay on this surface.
LockContent {
id: content
anchors.fill: parent
controller: surface.controller
hostSurface: surface
player: surface.player
notificationData: surface.notificationData
battery: surface.battery
}
}

View file

@ -0,0 +1,45 @@
import QtQuick
import Quickshell
Item {
id: body
required property var projection
required property color foreground
Column {
anchors.fill: parent; anchors.margins: 9; spacing: 5
Text {
width: parent.width; color: body.foreground; font.pixelSize: 13
text: "Notifications · " + (body.projection ? body.projection.total : 0)
}
Repeater {
// CONTRACT: show at most two public app counters, never private
// notification bodies or extra fields from the projection.
model: body.projection ? body.projection.apps.slice(0, 2) : []
Row {
required property var modelData
width: parent.width; spacing: 8
Item {
width: 16; height: 16
Image {
id: appIcon
anchors.fill: parent
source: modelData.icon ? Quickshell.iconPath(modelData.icon, true) : ""
sourceSize.width: 32; sourceSize.height: 32
visible: status === Image.Ready
}
Text {
anchors.centerIn: parent
visible: !appIcon.visible
text: ""; color: body.foreground; opacity: 0.68
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 12
}
}
Text {
width: parent.width - 24; color: body.foreground
font.pixelSize: 12; elide: Text.ElideRight
text: modelData.name + " · " + modelData.count
}
}
}
}
}

View file

@ -0,0 +1,83 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
// TEST_ONLY: this file cannot acquire a session lock and never creates PAM.
// All preview content is synthetic; the production wrapper selects shell.qml.
ShellRoot {
PanelWindow {
id: preview
screen: Quickshell.screens[0]
anchors { left: true; right: true; top: true; bottom: true }
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "labfy-lock-preview-non-locked"
color: "#1e1e2e"
readonly property string scenario: Quickshell.env("LABFY_LOCK_PREVIEW_SCENARIO") || "both"
readonly property bool mediaPresent: ["both", "media", "long", "noart"].includes(scenario)
// TEST_ONLY: the override lets the fixture exercise long/invalid URIs
// using a copy of its own synthetic cover; production ignores it.
readonly property string artUri: Quickshell.env("LABFY_LOCK_PREVIEW_ART_URI")
|| Qt.resolvedUrl("preview-cover.png").toString()
readonly property bool notificationsPresent: ["both", "notifications", "long"].includes(scenario)
readonly property bool batteryPresent: Quickshell.env("LABFY_LOCK_PREVIEW_BATTERY") !== "absent"
readonly property string feedback: Quickshell.env("LABFY_LOCK_PREVIEW_FEEDBACK") === "error"
? "Authentification refusée. Appuyez sur Entrée pour réessayer."
: Quickshell.env("LABFY_LOCK_PREVIEW_FEEDBACK") === "pam"
? "Message PAM de démonstration." : ""
readonly property bool feedbackIsError: Quickshell.env("LABFY_LOCK_PREVIEW_FEEDBACK") === "error"
readonly property bool capsOn: Quickshell.env("LABFY_LOCK_PREVIEW_CAPS") === "on"
readonly property bool promptMode: Quickshell.env("LABFY_LOCK_PREVIEW_FEEDBACK") === "prompt"
readonly property var dummySurface: ({ secure: preview.promptMode })
readonly property var dummyController: ({
displayName: "Compte de démonstration", loginUser: "demo", avatar: "",
attempt: preview.promptMode,
activeSurface: preview.promptMode ? preview.dummySurface : null,
secure: preview.promptMode,
pamResponseRequired: preview.promptMode, prompt: preview.promptMode ? "Mot de passe" : "",
responseVisible: false, aborting: false, keyboardLayout: "Français",
capsObserved: preview.capsOn, capsOn: preview.capsOn,
feedback: preview.feedback, feedbackIsError: preview.feedbackIsError,
submit: function() {}, cancel: function() {}, chooseSurface: function() {}
})
readonly property var dummyPlayer: mediaPresent ? ({
trackTitle: scenario === "long" ? "Un titre synthétique très long pour vérifier que le texte reste dans la carte sans déplacer les commandes" : "Morceau de démonstration",
trackArtist: "Artiste fictif",
trackArtUrl: scenario === "noart" ? "" : preview.artUri,
isPlaying: true,
canGoPrevious: true, canTogglePlaying: true, canGoNext: true,
positionSupported: false, length: 0
}) : null
readonly property var dummyNotifications: notificationsPresent ? ({
total: 3, apps: [
{ name: scenario === "long" ? "Application de démonstration au nom volontairement très long" : "Messagerie fictive", count: 2, icon: "mail-unread" },
{ name: "Agenda fictif", count: 1, icon: "x-office-calendar" }
]
}) : null
readonly property var dummyBattery: batteryPresent ? ({ isPresent: true, percentage: 0.73, state: -1 }) : null
LockContent {
anchors.fill: parent
preview: true
previewState: ({ time: "18:42", date: "Vendredi 9 octobre", mediaMode: "metadata" })
controller: preview.dummyController
hostSurface: preview.dummySurface
player: preview.dummyPlayer
notificationData: preview.dummyNotifications
battery: preview.dummyBattery
}
Rectangle {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
width: warning.implicitWidth + 24; height: 30
color: "#1e1e2e"; border.color: "#f38ba8"; radius: 4
Text {
id: warning
anchors.centerIn: parent
text: "APERÇU — NON VERROUILLÉ"
color: "#f38ba8"; font.pixelSize: 14; font.bold: true
}
}
}
}

View file

@ -0,0 +1,222 @@
# labfy-lock V1 — verrouillage de production
`labfy-lock` lance une configuration QuickShell distincte de `labfy-sway`.
Elle n'importe ni `shell.qml` ni `Bar.qml` et ne crée ni serveur de
notifications, ni agent Polkit, ni contrôleur d'apparence. `WlSessionLock`
demande au compositeur une surface opaque par sortie. Une seule conversation
`PamContext` est partagée par toutes les sorties. Le wrapper sérialise les
demandes avec `flock`, associe le PID à son heure de création `/proc` et à
une génération aléatoire, puis interroge **ce PID** jusqu'à `secure`. Son
retour ne signifie jamais que PAM a réussi.
## État du lot
Le processus et le wrapper sont installés par les liens Stow. Le raccourci
Sway `$mod+Alt+o`, le bouton Verrouiller du Control Center et les commandes
`timeout 300` et `before-sleep` de `swayidle.service` appellent tous
`/home/fy59/.local/bin/labfy-lock`. Les délais 300/360/420 s, l'alimentation
des sorties, la pause média et `idlehint 420` restent identiques. Le wrapper
rend la main après la confirmation `secure`, sans attendre le déverrouillage.
Le service PAM dédié `labfy-lock` est installé sous `/etc/pam.d/labfy-lock`.
Le paquet `swaylock` a été retiré après deux déverrouillages réels réussis,
dont un après sa suppression. Le fichier PAM qu'il fournissait a disparu ;
la politique dédiée reste installée.
Aucune action de veille/reprise physique n'a été testée.
La source suivie de la politique est
[`system/pam.d/labfy-lock`](../../../../system/pam.d/labfy-lock), avec sa
[procédure de réinstallation](../../../../system/pam.d/README.md). Un
déverrouillage réel avec `config: "labfy-lock"` a obtenu `PamResult.Success`,
libéré `WlSessionLock` et rendu le bureau avant et après suppression du paquet.
Le paquet `swaylock` n'est plus requis pour cette authentification.
## PAM et saisie
`PamContext` nomme explicitement le service `/etc/pam.d/labfy-lock`, qui
contient `auth include login`, comme l'ancienne politique `swaylock`.
La chaîne `login` inclut `system-local-login`, `system-login`, puis
`system-auth` pour la phase `auth`, notamment `pam_faillock` et `pam_unix`.
QuickShell 0.3.1 ne prend en charge que `auth` : cela ne démontre pas une
équivalence des phases `account` et `session` du binaire `swaylock`.
Les piles PAM globales ne sont pas modifiées.
L'utilisateur provient de l'UID effectif via `pwd.getpwuid`, sans identifiant
codé en dur. La conversation PAM démarre dès que le verrou est `secure`.
Entrée répond uniquement lorsque `responseRequired` est vrai ; si la demande
n'est pas encore prête, la saisie reste dans le champ. Le libellé et la
visibilité proviennent de PAM.
Échap vide la saisie et annule la conversation. Seul `PamResult.Success` de
la conversation non annulée, avec verrou `secure`, appelle `unlock()`.
`Failed`, `Error`, `MaxTries` et les réponses tardives ne libèrent rien.
La zone de saisie est vidée après envoi et fin de conversation. Cela ne
garantit pas l'effacement physique de toute la mémoire Qt/QML.
La disposition active vient de `swaymsg get_inputs` quand disponible.
L'état initial de Verr. maj. n'est pas exposé par cette source ; l'indication
après une pression sur la touche est indicative, pas un contrôle matériel.
## Apparence et confidentialité
Le fond provient de `labfy-appearance/effective.json`, pas d'une capture.
La palette Catppuccin effective est lue sans lancer de second contrôleur.
Une couleur opaque reste présente si le fond ou la palette manque. L'avatar
vient exclusivement d'une image PNG AccountsService validée, sinon un
pictogramme neutre. Le média MPRIS exclut `playerctld`, respecte les
capacités des boutons et ne charge que des pochettes `file://` bornées.
La progression n'est montrée que si le lecteur expose une position fiable.
Aucune action d'ouverture d'application, navigateur ou URI n'est présente.
Depuis le bureau déverrouillé :
```sh
labfy-lock-settings media hidden # masque la carte
labfy-lock-settings media generic # commandes, sans métadonnées
labfy-lock-settings media metadata # titre, artiste et pochette locale
```
Le serveur de notifications de la barre publie une projection bornée dans
`$XDG_RUNTIME_DIR` : icône, nom public et compteurs, au plus quatre
lignes. Le lockscreen ne lit jamais l'historique privé. Si la barre n'a pas
encore publié ce fichier, la carte est absente. Sa présentation ne marque
aucune notification comme lue.
Le wrapper pose un garde de capture **avant** de démarrer QuickShell.
L'Overview et son backend refusent alors les captures automatiques. Le garde
reste actif après un crash ; il passe à `released` seulement après le
déverrouillage PAM. Ce fichier est une protection des captures, pas une preuve
du verrouillage Wayland. `Quickshell.watchFiles = false` désactive le
rechargement du code dans le processus verrouilleur ; les FileView de données
peuvent toujours se mettre à jour.
## Essai visuel et essai réel
La fixture autonome ne demande jamais de mot de passe ni PAM :
```sh
quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
```
Elle affiche « APERÇU — NON VERROUILLÉ » et des données synthétiques.
Elle vérifie les proportions et la suppression des cartes sur petit écran,
mais ne remplace pas un essai de la vraie surface de verrou.
Avant un essai réel, arrêter les captures et diagnostics pouvant
observer la saisie, vérifier l'accès à un TTY distinct et conserver une
session disponible pour la récupération. L'utilisateur lance localement
`labfy-lock`, saisit lui-même son secret, puis confirme le retour au bureau.
Ne jamais transmettre le secret dans un chat, un script ou un fichier.
Le déclenchement par l'IPC Sway de la commande du raccourci a été confirmé
jusqu'à l'état `secure`. Le timeout réel de 300 s, l'extinction d'écran et
la séquence d'inactivité ont ensuite été validés en usage réel.
## Récupération et retour arrière
En SwayFX headless isolé, après un `SIGKILL` du verrouilleur confirmé, une
nouvelle instance `labfy-lock` a obtenu `secure`. Cela teste une voie de
récupération pour **ce build** ; cela ne garantit pas tous les états d'une
session physique ni la conservation de l'état après une panne du compositeur.
Un crash laisse l'écran protégé par la politique du compositeur. Depuis un
TTY distinct et avec le même utilisateur, on peut tenter une nouvelle
instance en visant le socket Wayland de la session :
```sh
XDG_RUNTIME_DIR=/run/user/$(id -u) WAYLAND_DISPLAY=wayland-1 \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus \
~/.local/bin/labfy-lock
```
Vérifier le vrai nom du socket avant cette commande. Si un autre verrou
`WlSessionLock` est déjà présent, ne pas le tuer : le second verrou est
refusé. Aucun repli automatique vers un autre verrouilleur n'a lieu après
acquisition. Un échec du wrapper ne retarde pas indéfiniment logind ; le
délai observé est de cinq secondes.
Pour un retour arrière PAM, réinstaller d'abord le paquet avec
`sudo pacman -S swaylock`, confirmer l'existence de `/etc/pam.d/swaylock`,
remettre temporairement `config: "swaylock"` dans le seul `PamContext`,
recharger uniquement le lockscreen si nécessaire, puis refaire un essai
manuel. Ne pas exécuter ce retour arrière tant que `labfy-lock` fonctionne.
Pour revenir aussi au verrouilleur historique, restaurer dans la source suivie
`systemd/.config/systemd/user/swayidle.service` exactement
`timeout 300 'swaylock -f -c 000000'` et
`before-sleep 'playerctl -a pause 2>/dev/null || true; swaylock -f -c 000000'`.
Puis exécuter `systemctl --user daemon-reload` et redémarrer
`swayidle.service` uniquement s'il était actif avant le rollback. Restaurer
également `exec swaylock` dans `sway/.config/sway/bind` et `["swaylock"]`
pour l'action Verrouiller du Control Center, puis recharger Sway. Ne jamais
lancer `swaylock` par-dessus un `WlSessionLock` encore détenu.
## Validation isolée
QuickShell 0.3.1, SwayFX 0.6. Tests : `qmllint`, garde PAM, projection privée,
wrapper concurrent, refus d'un second `WlSessionLock`, réacquisition après crash, ajout et
retrait d'une sortie avec échelle 1,5 et redimensionnement, aperçu sur
1280×720 et 800×600. Démarrage jusqu'à `secure` : 0,209 à 0,216 s sur trois
sessions headless fraîches ; RSS 443 à 448 Mo, PSS environ 341 Mo avec
`WLR_RENDERER=gles2` logiciel. Un verrou QuickShell minimal mesure déjà
environ 308 Mo RSS et 219 Mo PSS dans cette configuration ; ces valeurs ne
prédisent pas la consommation sur le GPU physique. Le 9 octobre 2026, un
déverrouillage PAM sur la session physique `tty1` a été confirmé par l'arrêt
du verrouilleur et le passage du garde de capture à `released`. La
veille/reprise et un ensemble de sorties physiques n'ont pas été validés.
## Composition visuelle V2 (historique, remplacée par V3)
`LockContent.qml` sert à la surface verrouillée et à la fixture non verrouillée.
L'heure et la date en français sont centrées en haut ; la batterie, quand elle
existe, reste sous la date. L'avatar, l'identité, la demande PAM, la saisie,
la disposition clavier et une ligne de retour réservée forment un bloc central
stable. Les cartes média et notifications publiques sont réunies en bas,
centrées, avec au plus deux cartes de 420 px chacune. Chaque carte absente
libère sa place. Sous 900 px de largeur, elles s'empilent ; sous 850 px de
hauteur dans ce mode, elles disparaissent pour préserver le formulaire. Sous
680 px de hauteur, les cartes disparaissent également.
La fixture emploie uniquement des données synthétiques, sans fond personnel.
Les variables suivantes permettent de vérifier les états, sans PAM ni verrou :
```sh
LABFY_LOCK_PREVIEW_SCENARIO=both quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
LABFY_LOCK_PREVIEW_SCENARIO=media quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
LABFY_LOCK_PREVIEW_SCENARIO=notifications quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
LABFY_LOCK_PREVIEW_SCENARIO=none LABFY_LOCK_PREVIEW_BATTERY=absent \
LABFY_LOCK_PREVIEW_FEEDBACK=error LABFY_LOCK_PREVIEW_CAPS=on \
quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
LABFY_LOCK_PREVIEW_SCENARIO=long quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
LABFY_LOCK_PREVIEW_FEEDBACK=pam quickshell --path ~/.config/quickshell/labfy-lock/Preview.qml
```
Capture synthétique : `docs/assets/screenshots/quickshell-lock-style-v2-synthetic.png`.
## Composition visuelle V3
La surface et la fixture partagent désormais une seule colonne horizontale
centrée : heure, date, avatar, identité, batterie discrète, demande et champ de
saisie, zone stable pour disposition clavier/Caps Lock et retour PAM, player,
puis notifications. Les deux cartes secondaires sont immédiatement sous la
zone d'authentification et disparaissent séparément si leurs données manquent.
Elles restent sous le champ à 1280×720 et 800×600, avec une pochette plus petite
sur écran compact. Aucune carte n'est placée dans un coin.
Le mode média par défaut est désormais `metadata` pour afficher la pochette
lorsqu'elle existe. Dans ce mode, une pochette locale `file://` de 4 096 caractères
au plus est affichée à 72 px, y compris en format compact, avec titre et artiste
tronqués. Si l'URI manque ou si l'image ne se charge pas, le repli est un
pictogramme de 32 px. Les URI distantes restent exclues par la politique locale
existante. Le mode `generic` configuré par l'utilisateur
conserve sa politique de masquage des métadonnées. Seules les commandes
supportées par le lecteur sont visibles. Les notifications n'affichent que deux
applications publiques au plus, avec icône, nom et compteur ; ni résumé ni
corps privé ne sont lus ou affichés.
La fixture utilise une pochette générée pour les scénarios `both`, `media` et
`long`. `LABFY_LOCK_PREVIEW_SCENARIO=noart` montre le repli sans pochette.
`none`, `notifications`, `LABFY_LOCK_PREVIEW_FEEDBACK=error|pam|prompt` et
`LABFY_LOCK_PREVIEW_CAPS=on` couvrent les autres états synthétiques.
Le prompt PAM normal reste au-dessus du champ, sans doublon rouge sous la saisie.
Seules les erreurs PAM et d'authentification sont rouges ; une information PAM
éventuelle conserve la couleur neutre. La fixture accepte aussi
`LABFY_LOCK_PREVIEW_ART_URI` pour une copie locale synthétique à URI longue.
Capture V3 sans données personnelles :
`docs/assets/screenshots/quickshell-lock-style-v3-synthetic.png`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,179 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Pam
import "AuthGate.js" as AuthGate
ShellRoot {
id: root
// CONTRACT: this process never reloads its code while it owns a session lock.
Component.onCompleted: Quickshell.watchFiles = false
readonly property string generation: String(Quickshell.env("LABFY_LOCK_GENERATION") || "")
readonly property string loginUser: String(Quickshell.env("LABFY_LOCK_USER") || Quickshell.env("USER") || "")
readonly property string displayName: String(Quickshell.env("LABFY_LOCK_DISPLAY") || loginUser)
readonly property string avatar: String(Quickshell.env("LABFY_LOCK_AVATAR") || "")
property FileView captureGuard: FileView {
path: (Quickshell.env("XDG_RUNTIME_DIR") || "") + "/labfy-lock.capture-guard"
blockWrites: true
atomicWrites: true
}
property var surfaces: []
property var activeSurface: null
property bool attempt: false
property bool aborting: false
property bool responding: false
property bool authorized: false
property string feedback: ""
property bool feedbackIsError: false
property string keyboardLayout: ""
property bool capsObserved: false
property bool capsOn: false
readonly property bool pamResponseRequired: pam.responseRequired
readonly property bool responseVisible: pam.responseVisible
readonly property string prompt: pam.message
function addSurface(surface) {
surfaces = surfaces.concat([surface]);
if (!activeSurface) chooseSurface(surface);
}
function removeSurface(surface) {
surfaces = surfaces.filter(item => item !== surface);
if (activeSurface === surface) {
activeSurface = null;
if (surfaces.length) chooseSurface(surfaces[0]);
}
}
function chooseSurface(surface) {
if (activeSurface && activeSurface.field) activeSurface.field.text = "";
activeSurface = surface;
if (surface && surface.field) Qt.callLater(() => surface.field.forceActiveFocus());
}
function cancel() {
if (activeSurface && activeSurface.field) activeSurface.field.text = "";
if (attempt && pam.active) {
aborting = true;
pam.abort();
}
feedback = "Saisie effacée.";
feedbackIsError = false;
}
function startAuthentication() {
if (!lock.secure || attempt || authorized || aborting) return;
attempt = true;
feedback = "";
feedbackIsError = false;
if (!pam.start()) {
attempt = false;
feedback = "Authentification indisponible.";
feedbackIsError = true;
}
}
function submit(value) {
if (!lock.secure || authorized || aborting || responding) return;
if (!attempt) startAuthentication();
// INVARIANT: an early Enter cannot discard a secret before PAM asks for it.
if (!attempt || !pam.responseRequired) return;
// CONTRACT: an empty submission leaves the current PAM prompt pending.
if (value.length === 0) return;
responding = true;
pam.respond(value);
if (activeSurface && activeSurface.field) activeSurface.field.text = "";
responding = false;
}
// INVARIANT: no IPC method mutates the lock. A caller only observes this
// exact process generation and compositor confirmation.
IpcHandler {
target: "lock"
function state(): string {
return root.generation + ":" + (lock.secure && lock.locked ? "secure" : "pending");
}
}
Process {
// WHY: Sway exposes the active layout, but not a reliable initial
// CapsLock state in get_inputs. We report the latter only after a key event.
command: ["swaymsg", "-t", "get_inputs", "-r"]
running: true
stdout: StdioCollector { id: inputData; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0 || inputData.text.length > 65536) return;
try {
const inputs = JSON.parse(inputData.text);
const keyboard = inputs.find(item => item.type === "keyboard"
&& item.identifier && item.identifier.includes("AT_Translated"))
|| inputs.find(item => item.type === "keyboard" && item.xkb_active_layout_name);
if (keyboard && typeof keyboard.xkb_active_layout_name === "string")
root.keyboardLayout = keyboard.xkb_active_layout_name.slice(0, 48);
} catch (_) {}
}
}
PamContext {
id: pam
// CONTRACT: la politique auth dédiée reprend « auth include login » ;
// le fichier appartient à root sous /etc/pam.d/labfy-lock.
config: "labfy-lock"
user: root.loginUser
onPamMessage: {
// CONTRACT: a PAM response prompt is already shown above the field;
// only informational messages and actual errors use the lower line.
root.feedback = pam.responseRequired && !pam.messageIsError ? "" : pam.message;
root.feedbackIsError = pam.messageIsError;
root.responding = false;
if (root.activeSurface && root.activeSurface.field)
Qt.callLater(() => root.activeSurface.field.forceActiveFocus());
}
onError: error => {
root.feedback = "Erreur d’authentification.";
root.feedbackIsError = true;
}
onCompleted: result => {
const valid = AuthGate.mayUnlock(root.attempt, root.aborting,
lock.secure, result, PamResult.Success);
root.attempt = false;
root.responding = false;
root.aborting = false;
if (root.activeSurface && root.activeSurface.field) root.activeSurface.field.text = "";
if (valid) {
root.authorized = true;
// CONTRACT: QuickShell releases this Wayland session lock by
// changing its locked property after PAM succeeds.
lock.locked = false;
} else {
root.feedback = result === PamResult.MaxTries
? "Nombre maximal de tentatives atteint."
: "Authentification refusée. Appuyez sur Entrée pour réessayer.";
root.feedbackIsError = true;
}
}
}
// WHY: the surface component is instantiated by QuickShell for every
// compositor output, including outputs plugged in after acquisition.
WlSessionLock {
id: lock
locked: true
onLockStateChanged: {
// CONTRACT: an acquisition refusal cannot leave an inert second
// locker occupying the wrapper's singleton record.
if (!locked && !root.authorized) Qt.quit();
}
onSecureStateChanged: {
if (!secure && root.authorized) {
// CONTRACT: exit only after the authenticated unlock has
// removed compositor protection, so the next request is fresh.
root.captureGuard.setText("released\n");
Qt.quit();
return;
}
if (secure) {
// WHY: PAM is ready before the user types the first password.
root.startAuthentication();
if (root.activeSurface && root.activeSurface.field)
Qt.callLater(() => root.activeSurface.field.forceActiveFocus());
}
}
LockSurface { controller: root; secure: lock.secure }
}
}

View file

@ -30,6 +30,19 @@ PanelWindow {
required property var drawerService
required property var osdService
screen: modelData
FileView {
id: lockCaptureGuardFile
path: (Quickshell.env("XDG_RUNTIME_DIR") || "") + "/labfy-lock.capture-guard"
printErrors: false
}
readonly property bool lockCaptureGuard: lockCaptureGuardFile.text().trim() === "active"
onLockCaptureGuardChanged: {
if (lockCaptureGuard) {
overviewCapture.running = false;
overviewCaptureRefresh.stop();
closeOverview();
}
}
function closeDrawer() { drawerIndicator.close(false) }
function toggleDrawer() { drawerIndicator.toggle() }
@ -215,7 +228,7 @@ PanelWindow {
return true;
}
function openOverview() {
if (authenticationActive) return false;
if (authenticationActive || lockCaptureGuard) return false;
// INVARIANT: deux ouvertures ne créent ni une seconde capture ni une fermeture.
if (overviewRequested || overviewLoader.active) return true;
closeApplications();
@ -230,7 +243,7 @@ PanelWindow {
return true;
}
function toggleOverview() {
if (authenticationActive) return false;
if (authenticationActive || lockCaptureGuard) return false;
if (overviewRequested || overviewLoader.active) return closeOverview();
return openOverview();
}
@ -265,7 +278,7 @@ PanelWindow {
id: overviewCapture
command: ["python3", "-B", bar.overviewBackend, "capture", "--output", bar.screen.name]
onExited: (code, status) => {
if (bar.overviewRequested && !bar.authenticationActive) overviewLoader.active = true;
if (bar.overviewRequested && !bar.authenticationActive && !bar.lockCaptureGuard) overviewLoader.active = true;
else if (code !== 0) console.warn("Capture du workspace impossible");
}
}
@ -281,7 +294,7 @@ PanelWindow {
id: overviewCaptureRefresh
interval: 350; repeat: false
onTriggered: {
if (!bar.authenticationActive && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
if (!bar.authenticationActive && !bar.lockCaptureGuard && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !applicationsLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
@ -292,7 +305,7 @@ PanelWindow {
Timer {
interval: 15000; repeat: true; running: true; triggeredOnStart: true
onTriggered: {
if (!bar.authenticationActive && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
if (!bar.authenticationActive && !bar.lockCaptureGuard && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !applicationsLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;

View file

@ -93,7 +93,8 @@ est 60 % ; le helper root est documenté dans `bin/root/README.md`.
Dépendances essentielles : `quickshell`, SwayFX 0.6, `scenefx`, une Nerd Font,
NetworkManager, BlueZ, PipeWire, TLP/`tlpctl`, `checkupdates`, `yay`, `df`,
`python3`, `swaylock` et le helper batterie pour le réglage du seuil. Le
`python3`, la politique `/etc/pam.d/labfy-lock` et le helper batterie pour le
réglage du seuil. Le
backlight utilise le noeud machine `amdgpu_bl1` dans `BrightnessSlider.qml`.
Les scans Wi-Fi et Bluetooth
ne sont possédés que par les pages ouvertes.

View file

@ -43,7 +43,7 @@ PopupWindow {
+ "/quickshell/labfy-sway/session-v2.py"
// Une seule table associe les libellés, confirmations et commandes de session.
readonly property var sessionActions: [
{ id: "lock", label: "Verrouiller", icon: "", accent: Theme.accent, command: ["swaylock"] },
{ id: "lock", label: "Verrouiller", icon: "", accent: Theme.accent, command: ["/home/fy59/.local/bin/labfy-lock"] },
{ id: "suspend", label: "Veille", icon: "", accent: Theme.accent,
confirmTitle: "Mettre l'ordinateur en veille ?",
description: "La session sera verrouillée avant la veille.",

View file

@ -0,0 +1,19 @@
// CONTRACT: only public application identity and counts cross into the lock
// process. The private record never enters the serialized result.
function project(records) {
const groups = {};
let total = 0;
for (const record of records.slice(0, 200)) {
if (!record || record.read !== false) continue;
total++;
const name = typeof record.appName === "string"
&& /^[^\r\n\t]{1,48}$/.test(record.appName) ? record.appName : "Application";
const icon = typeof record.appIcon === "string"
&& /^[A-Za-z0-9_.-]{1,64}$/.test(record.appIcon) ? record.appIcon : "";
const key = name + "\n" + icon;
if (!groups[key]) groups[key] = { name: name, icon: icon, count: 0 };
groups[key].count++;
}
return { version: 1, generatedAt: Date.now(), total: total,
apps: Object.values(groups).slice(0, 4) };
}

View file

@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import "LockProjection.js" as LockProjection
QtObject {
id: store
@ -24,6 +25,17 @@ QtObject {
onLoadFailed: store.load()
onSaveFailed: error => console.warn("Historique notifications : écriture échouée", error)
}
// CONTRACT: the lock process receives only a bounded public projection.
// Summary, body, image, sender, actions and record identifiers never cross.
property FileView lockFile: FileView {
path: (Quickshell.env("XDG_RUNTIME_DIR") || "") + "/labfy-lock-notifications.json"
blockWrites: true
atomicWrites: true
printErrors: false
}
function publishLockProjection() {
lockFile.setText(JSON.stringify(LockProjection.project(records)));
}
function load() {
if (ready) return;
@ -50,6 +62,7 @@ QtObject {
return;
}
ready = true;
publishLockProjection();
// INVARIANT: la migration conserve chaque champ utilisateur et persiste le schéma v2.
if (migrationNeeded) save();
}
@ -58,6 +71,7 @@ QtObject {
function save() {
if (!ready) return;
file.setText(JSON.stringify({ version: 2, nextId: nextId, records: records }));
publishLockProjection();
}
function upsert(record) {

View file

@ -13,6 +13,18 @@ import tempfile
import time
def lock_capture_guard_active():
# CONTRACT: capture is denied while the dedicated lock may render an auth
# surface. A crash leaves the guard active until authenticated recovery.
runtime = os.environ.get("XDG_RUNTIME_DIR")
if not runtime:
return False
try:
return (Path(runtime) / "labfy-lock.capture-guard").read_text() == "active\n"
except OSError:
return False
def sway(kind):
result = subprocess.run(["swaymsg", "-t", kind, "-r"], capture_output=True,
text=True, timeout=5, check=True)
@ -224,6 +236,8 @@ def state():
def capture(output, expected_number=None, expected_workspace_id=None,
expected_revision=None, settle_ms=0):
if lock_capture_guard_active():
return {"captured": False}
if settle_ms < 0 or settle_ms > 1000:
raise ValueError("Délai de capture invalide")
if settle_ms:
@ -242,6 +256,8 @@ def capture(output, expected_number=None, expected_workspace_id=None,
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".jpg", delete=False) as temp:
temporary = Path(temp.name)
try:
if lock_capture_guard_active():
return {"captured": False}
subprocess.run(["grim", "-o", output, "-s", "0.25", "-t", "jpeg", "-q", "72",
str(temporary)], check=True, timeout=8, capture_output=True)
if not temporary.is_file() or temporary.stat().st_size == 0:
@ -250,7 +266,7 @@ def capture(output, expected_number=None, expected_workspace_id=None,
updated = next((card for card in after["cards"] if card["number"] == current["number"]), None)
# INVARIANT: un changement pendant grim ou la présence de l'Overview
# interdit de publier une image associée au mauvais arbre.
if (output in after["overlayOutputs"] or not updated or not updated["visible"]
if (lock_capture_guard_active() or output in after["overlayOutputs"] or not updated or not updated["visible"]
or updated["output"] != output or updated["workspaceId"] != current["workspaceId"]
or updated["revision"] != current["revision"]):
return {"captured": False}
@ -275,6 +291,8 @@ def capture(output, expected_number=None, expected_workspace_id=None,
def capture_windows(numbers, cursor=0):
if lock_capture_guard_active():
return {"captured": [], "nextCursor": 0}
if cursor < 0:
raise ValueError("Curseur de capture invalide")
before = state()
@ -287,6 +305,8 @@ def capture_windows(numbers, cursor=0):
captures = []
try:
for window in batch:
if lock_capture_guard_active():
break
with tempfile.NamedTemporaryFile(dir=cache_dir(), suffix=".jpg", delete=False) as temp:
temporary = Path(temp.name)
captures.append((window, temporary))
@ -302,6 +322,8 @@ def capture_windows(numbers, cursor=0):
current = {window["id"]: window for card in after["cards"] for window in card["windows"]}
published = []
for window, temporary in captures:
if lock_capture_guard_active():
break
if not temporary.is_file() or current.get(window["id"], {}).get("foreignId") != window["foreignId"]:
continue
path = window_preview_path(window["id"])

View file

@ -215,7 +215,7 @@ bindsym $mod+Alt+a exec AppFlowy
bindsym $mod+Alt+g exec gimp
bindsym $mod+Alt+m exec limusic-app
bindsym $mod+Alt+b exec blueman-manager
bindsym $mod+Alt+o exec swaylock
bindsym $mod+Alt+o exec /home/fy59/.local/bin/labfy-lock
bindsym $mod+Alt+l exec ~/.config/sway/scripts/applications-quickshell
bindsym $mod+Alt+p exec proton-pass
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton

41
system/pam.d/README.md Normal file
View file

@ -0,0 +1,41 @@
# Politique PAM de labfy-lock
`labfy-lock` emploie la phase `auth` de PAM. Le fichier source
[`labfy-lock`](labfy-lock) reprend exactement la règle active du paquet
`swaylock` : `auth include login`. La chaîne d'authentification sur cette
installation est `login` → `system-local-login` → `system-login` →
`system-auth`. Elle conserve notamment `pam_nologin`, `pam_shells`,
`pam_faillock`, `pam_unix` et les modules standard de cette pile. Les phases
`account`, `password` et `session` ne sont pas ajoutées : `PamContext` de
QuickShell 0.3.1 utilise seulement `auth`.
Après une reconstruction du système, installer depuis la racine du dépôt :
```sh
sudo install -o root -g root -m 0644 system/pam.d/labfy-lock /etc/pam.d/labfy-lock
```
Le mot de passe d'administration se saisit uniquement dans le dialogue
normal de `sudo`. Ne pas employer Stow pour `/etc/pam.d`.
Vérifier le fichier installé avant de changer `PamContext` :
```sh
stat -c '%U:%G %a %n' /etc/pam.d/labfy-lock
cmp system/pam.d/labfy-lock /etc/pam.d/labfy-lock
test -f /etc/pam.d/login
test -f /etc/pam.d/system-local-login
test -f /etc/pam.d/system-login
test -f /etc/pam.d/system-auth
```
Après installation, régler `config: "labfy-lock"` dans le seul `PamContext`
du lockscreen, vérifier le QML et les tests, puis faire un essai réel avec
saisie du secret par l'utilisateur. Conserver le paquet `swaylock` jusqu'à
confirmation du retour au bureau. Un test synthétique ne valide pas PAM.
En cas d'échec après désinstallation du paquet, exécuter d'abord
`sudo pacman -S swaylock`, puis confirmer que `/etc/pam.d/swaylock` existe.
Remettre `config: "swaylock"`, recharger uniquement le lockscreen si
nécessaire et refaire un essai manuel. Ne pas lancer `swaylock`
par-dessus une session `WlSessionLock` déjà verrouillée.

6
system/pam.d/labfy-lock Normal file
View file

@ -0,0 +1,6 @@
#%PAM-1.0
# WHY: conserver la même politique auth que le verrou validé sans dépendre
# du fichier PAM fourni par le paquet swaylock.
# CONTRACT: PamContext utilise uniquement la phase auth ; login inclut
# system-local-login, system-login et system-auth pour cette phase.
auth include login

View file

@ -4,7 +4,8 @@ PartOf=graphical-session.target
After=graphical-session.target
[Service]
ExecStart=/usr/bin/swayidle -w timeout 300 'swaylock -f -c 000000' timeout 360 'swaymsg "output * power off"' resume 'swaymsg "output * power on"' timeout 420 'playerctl -a pause 2>/dev/null || true' idlehint 420 before-sleep 'playerctl -a pause 2>/dev/null || true; swaylock -f -c 000000'
# CONTRACT: -w attend la fin de la commande before-sleep ; labfy-lock rend la main uniquement après confirmation secure.
ExecStart=/usr/bin/swayidle -w timeout 300 '/home/fy59/.local/bin/labfy-lock' timeout 360 'swaymsg "output * power off"' resume 'swaymsg "output * power on"' timeout 420 'playerctl -a pause 2>/dev/null || true' idlehint 420 before-sleep 'playerctl -a pause 2>/dev/null || true; /home/fy59/.local/bin/labfy-lock'
Restart=on-failure
[Install]

View file

@ -14,6 +14,15 @@ spec.loader.exec_module(backend)
class OverviewBackendTests(unittest.TestCase):
def test_lock_guard_blocks_all_capture_entrypoints(self):
# CONTRACT: no compositor read or grim call follows an active lock
# guard, including the window-preview path.
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}):
(Path(directory) / "labfy-lock.capture-guard").write_text("active\n")
with patch.object(backend, "state", side_effect=AssertionError("capture reached state")):
self.assertEqual(backend.capture("HEADLESS-1"), {"captured": False})
self.assertEqual(backend.capture_windows({1}), {"captured": [], "nextCursor": 0})
def test_state_keeps_floating_and_excludes_layer_shell(self):
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
tiled = {"type": "con", "id": 10, "app_id": "native-app", "name": "Native",

View file

@ -9,7 +9,7 @@ TestCase {
id: gateComponent
SessionExitGate {
actions: [
{ id: "lock", label: "Verrouiller", command: ["swaylock"] },
{ id: "lock", label: "Verrouiller", command: ["/home/fy59/.local/bin/labfy-lock"] },
{ id: "suspend", label: "Veille", command: ["systemctl", "suspend"] },
{ id: "logout", label: "Déconnexion", confirmTitle: "Se déconnecter ?",
description: "La session SwayFX en cours sera fermée.", checkpointReason: "logout",
@ -128,7 +128,7 @@ TestCase {
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction("lock");
compare(commandSpy.count, 1);
compare(commandSpy.signalArguments[0][0], ["swaylock"]);
compare(commandSpy.signalArguments[0][0], ["/home/fy59/.local/bin/labfy-lock"]);
compare(checkpointSpy.count, 0);
gate.requestAction("suspend");
compare(gate.pendingAction.id, "suspend");

View file

@ -0,0 +1,101 @@
"""Contrôles TEST_ONLY sur un compositeur Sway headless enfant."""
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "session_v2/fixtures"))
from headless import Lab
ROOT = Path(__file__).resolve().parents[2]
WRAPPER = ROOT / "bin/.local/bin/labfy-lock"
class LockLifecycleTests(unittest.TestCase):
def test_refuses_second_lock(self):
with Lab() as lab:
# CONTRACT: un autre processus détient déjà WlSessionLock ; le
# wrapper ne doit pas déclarer sa propre acquisition secure.
lab.env["LABFY_LOCK_GENERATION"] = "foreign"
holder = lab.start(["quickshell", "--path", str(ROOT / "quickshell/.config/quickshell/labfy-lock")])
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
observed = subprocess.run(
["quickshell", "ipc", "--pid", str(holder.pid), "call", "lock", "state"],
env=lab.env, capture_output=True, text=True, timeout=2)
if observed.stdout.strip() == "foreign:secure":
break
time.sleep(.08)
self.assertEqual(observed.stdout.strip(), "foreign:secure")
attempt = subprocess.run([str(WRAPPER)], env=lab.env,
capture_output=True, text=True, timeout=12)
self.assertNotEqual(attempt.returncode, 0)
self.assertNotIn("secure", attempt.stdout)
def test_concurrent_secure_and_crash_reacquisition(self):
with Lab() as lab:
first = subprocess.Popen([str(WRAPPER)], env=lab.env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
second = subprocess.Popen([str(WRAPPER)], env=lab.env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.assertEqual(first.communicate(timeout=12)[1], "")
self.assertEqual(second.communicate(timeout=12)[1], "")
self.assertEqual((first.returncode, second.returncode), (0, 0))
record = json.loads((lab.directory / "labfy-lock.launch").read_text())
pid = record["pid"]
try:
observed = subprocess.run(
["quickshell", "ipc", "--pid", str(pid), "call", "lock", "state"],
env=lab.env, capture_output=True, text=True, timeout=2)
self.assertEqual(observed.stdout.strip(), record["generation"] + ":secure")
# No callable IPC path may authorize unlock.
forbidden = subprocess.run(
["quickshell", "ipc", "--pid", str(pid), "call", "lock", "unlock"],
env=lab.env, capture_output=True, text=True, timeout=2)
# Some QuickShell versions return 0 even for an unknown method;
# the compositor state is the authoritative observation.
self.assertNotIn(record["generation"] + ":secure", forbidden.stdout)
still_locked = subprocess.run(
["quickshell", "ipc", "--pid", str(pid), "call", "lock", "state"],
env=lab.env, capture_output=True, text=True, timeout=2)
self.assertEqual(still_locked.stdout.strip(), record["generation"] + ":secure")
# Dynamic outputs remain owned by the same lock conversation.
for command in ("create_output", "output HEADLESS-2 mode 800x600",
"output HEADLESS-2 scale 1.5",
"output HEADLESS-2 mode 1024x768",
"output HEADLESS-2 disable"):
lab.command(command)
time.sleep(.12)
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
state = subprocess.run(
["quickshell", "ipc", "--pid", str(pid), "call", "lock", "state"],
env=lab.env, capture_output=True, text=True, timeout=2)
if state.stdout.strip() == record["generation"] + ":secure":
break
time.sleep(.05)
self.assertEqual(state.stdout.strip(), record["generation"] + ":secure", command)
self.assertTrue(Path(f"/proc/{pid}").exists())
finally:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
deadline = time.monotonic() + 2
while time.monotonic() < deadline and Path(f"/proc/{pid}").exists():
time.sleep(.05)
retry = subprocess.run([str(WRAPPER)], env=lab.env,
capture_output=True, text=True, timeout=12)
self.assertEqual(retry.returncode, 0, retry.stderr)
replacement = json.loads((lab.directory / "labfy-lock.launch").read_text())
self.assertNotEqual(replacement["generation"], record["generation"])
os.kill(replacement["pid"], signal.SIGKILL)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,14 @@
const fs = require('fs');
const vm = require('vm');
const assert = require('assert');
const source = fs.readFileSync('quickshell/.config/quickshell/labfy-lock/AuthGate.js', 'utf8');
const context = vm.createContext({});
vm.runInContext(source, context);
const allowed = context.mayUnlock;
const Success = 0, Failed = 1, Error = 2, MaxTries = 3;
assert.equal(allowed(true, false, true, Success, Success), true);
for (const result of [Failed, Error, MaxTries])
assert.equal(allowed(true, false, true, result, Success), false);
assert.equal(allowed(true, true, true, Success, Success), false); // late result after abort
assert.equal(allowed(false, false, true, Success, Success), false); // inactive generation
assert.equal(allowed(true, false, false, Success, Success), false); // not secure

View file

@ -0,0 +1,21 @@
const fs = require('fs');
const vm = require('vm');
const assert = require('assert');
const source = fs.readFileSync('quickshell/.config/quickshell/labfy-sway/notifications/LockProjection.js', 'utf8');
const context = vm.createContext({});
vm.runInContext(source, context);
const result = JSON.parse(JSON.stringify(context.project([
{ read: false, appName: 'Messagerie', appIcon: 'mail-client',
summary: 'Code 123456', body: 'Contact privé', image: '/private.png' },
{ read: false, appName: 'Messagerie', appIcon: 'mail-client', summary: 'Autre secret' },
{ read: true, appName: 'Navigateur', summary: 'Ne pas montrer' },
{ read: false, appName: 'Nom\nSecret', appIcon: 'file:///private' }
])))
assert.equal(result.total, 3);
assert.deepEqual(result.apps, [
{ name: 'Messagerie', icon: 'mail-client', count: 2 },
{ name: 'Application', icon: '', count: 1 }
]);
const serialized = JSON.stringify(result);
for (const forbidden of ['123456', 'Contact privé', '/private.png', 'Autre secret', 'Nom\nSecret'])
assert(!serialized.includes(forbidden), forbidden);