diff --git a/.assets/pkglist-pacman.txt b/.assets/pkglist-pacman.txt index 72f2f0414..faec6f6de 100644 --- a/.assets/pkglist-pacman.txt +++ b/.assets/pkglist-pacman.txt @@ -180,7 +180,6 @@ strace sudo swaybg swayidle -swaylock swaync system-config-printer tcpdump diff --git a/README.md b/README.md index 447715208..a75ccbd84 100644 --- a/README.md +++ b/README.md @@ -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[@]}" diff --git a/bin/.local/bin/labfy-lock b/bin/.local/bin/labfy-lock new file mode 100755 index 000000000..574b450fa --- /dev/null +++ b/bin/.local/bin/labfy-lock @@ -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()) diff --git a/bin/.local/bin/labfy-lock-settings b/bin/.local/bin/labfy-lock-settings new file mode 100755 index 000000000..5b2eedbac --- /dev/null +++ b/bin/.local/bin/labfy-lock-settings @@ -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()) diff --git a/docs/assets/screenshots/quickshell-lock-style-v2-synthetic.png b/docs/assets/screenshots/quickshell-lock-style-v2-synthetic.png new file mode 100644 index 000000000..ffec288e0 Binary files /dev/null and b/docs/assets/screenshots/quickshell-lock-style-v2-synthetic.png differ diff --git a/docs/assets/screenshots/quickshell-lock-style-v3-synthetic.png b/docs/assets/screenshots/quickshell-lock-style-v3-synthetic.png new file mode 100644 index 000000000..268f10272 Binary files /dev/null and b/docs/assets/screenshots/quickshell-lock-style-v3-synthetic.png differ diff --git a/docs/assets/screenshots/quickshell-lock-style-v4-synthetic.png b/docs/assets/screenshots/quickshell-lock-style-v4-synthetic.png new file mode 100644 index 000000000..c06fcb6f6 Binary files /dev/null and b/docs/assets/screenshots/quickshell-lock-style-v4-synthetic.png differ diff --git a/docs/desktop.md b/docs/desktop.md index 24dc2edeb..2a1fe715e 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -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). diff --git a/docs/installation.md b/docs/installation.md index 21f1f6d54..5d25d3f05 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/docs/systemd.md b/docs/systemd.md index e4b266b4a..36f3971a1 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -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 diff --git a/portal/README.md b/portal/README.md index 3d254d203..14b15a6b1 100644 --- a/portal/README.md +++ b/portal/README.md @@ -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 ; diff --git a/quickshell/.config/quickshell/labfy-lock/Appearance.qml b/quickshell/.config/quickshell/labfy-lock/Appearance.qml new file mode 100644 index 000000000..66a0b413c --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/Appearance.qml @@ -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" +} diff --git a/quickshell/.config/quickshell/labfy-lock/AuthGate.js b/quickshell/.config/quickshell/labfy-lock/AuthGate.js new file mode 100644 index 000000000..923742ec3 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/AuthGate.js @@ -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; +} diff --git a/quickshell/.config/quickshell/labfy-lock/LockContent.qml b/quickshell/.config/quickshell/labfy-lock/LockContent.qml new file mode 100644 index 000000000..4364f12b2 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/LockContent.qml @@ -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 } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-lock/LockSurface.qml b/quickshell/.config/quickshell/labfy-lock/LockSurface.qml new file mode 100644 index 000000000..532c04bfd --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/LockSurface.qml @@ -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 + } +} diff --git a/quickshell/.config/quickshell/labfy-lock/NotificationCardBody.qml b/quickshell/.config/quickshell/labfy-lock/NotificationCardBody.qml new file mode 100644 index 000000000..25c53cb58 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/NotificationCardBody.qml @@ -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 + } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-lock/Preview.qml b/quickshell/.config/quickshell/labfy-lock/Preview.qml new file mode 100644 index 000000000..e305d111a --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/Preview.qml @@ -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 + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-lock/README.md b/quickshell/.config/quickshell/labfy-lock/README.md new file mode 100644 index 000000000..8b4b9cba9 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/README.md @@ -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`. diff --git a/quickshell/.config/quickshell/labfy-lock/preview-cover.png b/quickshell/.config/quickshell/labfy-lock/preview-cover.png new file mode 100644 index 000000000..a1135e6a2 Binary files /dev/null and b/quickshell/.config/quickshell/labfy-lock/preview-cover.png differ diff --git a/quickshell/.config/quickshell/labfy-lock/shell.qml b/quickshell/.config/quickshell/labfy-lock/shell.qml new file mode 100644 index 000000000..4f049eed7 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-lock/shell.qml @@ -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 } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index b8f543dcd..cc2dc750d 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -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; diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index 2e8b573fc..15d402f69 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 3fe33624a..1b10bb3c4 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -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.", diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/LockProjection.js b/quickshell/.config/quickshell/labfy-sway/notifications/LockProjection.js new file mode 100644 index 000000000..cc938dfff --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/notifications/LockProjection.js @@ -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) }; +} diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml index 3c83843b9..8512052d1 100644 --- a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml +++ b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml @@ -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) { diff --git a/quickshell/.config/quickshell/labfy-sway/overview/backend.py b/quickshell/.config/quickshell/labfy-sway/overview/backend.py index 5b2fcdd80..66c292699 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/overview/backend.py @@ -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"]) diff --git a/sway/.config/sway/bind b/sway/.config/sway/bind index eb3bf5fe4..ab6a6003a 100644 --- a/sway/.config/sway/bind +++ b/sway/.config/sway/bind @@ -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 diff --git a/system/pam.d/README.md b/system/pam.d/README.md new file mode 100644 index 000000000..84899d4e7 --- /dev/null +++ b/system/pam.d/README.md @@ -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. diff --git a/system/pam.d/labfy-lock b/system/pam.d/labfy-lock new file mode 100644 index 000000000..8b77ee7af --- /dev/null +++ b/system/pam.d/labfy-lock @@ -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 diff --git a/systemd/.config/systemd/user/swayidle.service b/systemd/.config/systemd/user/swayidle.service index 582038980..a11605273 100644 --- a/systemd/.config/systemd/user/swayidle.service +++ b/systemd/.config/systemd/user/swayidle.service @@ -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] diff --git a/tests/overview/test_backend.py b/tests/overview/test_backend.py index af97fecf2..ee0dac741 100644 --- a/tests/overview/test_backend.py +++ b/tests/overview/test_backend.py @@ -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", diff --git a/tests/qml/tst_session_exit_gate.qml b/tests/qml/tst_session_exit_gate.qml index 913785573..5e68f798c 100644 --- a/tests/qml/tst_session_exit_gate.qml +++ b/tests/qml/tst_session_exit_gate.qml @@ -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"); diff --git a/tests/quickshell/test_labfy_lock.py b/tests/quickshell/test_labfy_lock.py new file mode 100644 index 000000000..890d34ec2 --- /dev/null +++ b/tests/quickshell/test_labfy_lock.py @@ -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() diff --git a/tests/quickshell/test_lock_auth_gate.js b/tests/quickshell/test_lock_auth_gate.js new file mode 100644 index 000000000..37f1ac82b --- /dev/null +++ b/tests/quickshell/test_lock_auth_gate.js @@ -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 diff --git a/tests/quickshell/test_lock_projection.js b/tests/quickshell/test_lock_projection.js new file mode 100644 index 000000000..fca71a088 --- /dev/null +++ b/tests/quickshell/test_lock_projection.js @@ -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);