feat(quickshell): replace swaylock with native lockscreen
This commit is contained in:
parent
02236cfdeb
commit
9aa3bc8353
35 changed files with 1375 additions and 17 deletions
|
|
@ -180,7 +180,6 @@ strace
|
|||
sudo
|
||||
swaybg
|
||||
swayidle
|
||||
swaylock
|
||||
swaync
|
||||
system-config-printer
|
||||
tcpdump
|
||||
|
|
|
|||
|
|
@ -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
133
bin/.local/bin/labfy-lock
Executable 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())
|
||||
30
bin/.local/bin/labfy-lock-settings
Executable file
30
bin/.local/bin/labfy-lock-settings
Executable 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())
|
||||
BIN
docs/assets/screenshots/quickshell-lock-style-v2-synthetic.png
Normal file
BIN
docs/assets/screenshots/quickshell-lock-style-v2-synthetic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/assets/screenshots/quickshell-lock-style-v3-synthetic.png
Normal file
BIN
docs/assets/screenshots/quickshell-lock-style-v3-synthetic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/assets/screenshots/quickshell-lock-style-v4-synthetic.png
Normal file
BIN
docs/assets/screenshots/quickshell-lock-style-v4-synthetic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
50
quickshell/.config/quickshell/labfy-lock/Appearance.qml
Normal file
50
quickshell/.config/quickshell/labfy-lock/Appearance.qml
Normal 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"
|
||||
}
|
||||
6
quickshell/.config/quickshell/labfy-lock/AuthGate.js
Normal file
6
quickshell/.config/quickshell/labfy-lock/AuthGate.js
Normal 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;
|
||||
}
|
||||
281
quickshell/.config/quickshell/labfy-lock/LockContent.qml
Normal file
281
quickshell/.config/quickshell/labfy-lock/LockContent.qml
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
55
quickshell/.config/quickshell/labfy-lock/LockSurface.qml
Normal file
55
quickshell/.config/quickshell/labfy-lock/LockSurface.qml
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
quickshell/.config/quickshell/labfy-lock/Preview.qml
Normal file
83
quickshell/.config/quickshell/labfy-lock/Preview.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
quickshell/.config/quickshell/labfy-lock/README.md
Normal file
222
quickshell/.config/quickshell/labfy-lock/README.md
Normal 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`.
|
||||
BIN
quickshell/.config/quickshell/labfy-lock/preview-cover.png
Normal file
BIN
quickshell/.config/quickshell/labfy-lock/preview-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
179
quickshell/.config/quickshell/labfy-lock/shell.qml
Normal file
179
quickshell/.config/quickshell/labfy-lock/shell.qml
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
41
system/pam.d/README.md
Normal 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
6
system/pam.d/labfy-lock
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
101
tests/quickshell/test_labfy_lock.py
Normal file
101
tests/quickshell/test_labfy_lock.py
Normal 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()
|
||||
14
tests/quickshell/test_lock_auth_gate.js
Normal file
14
tests/quickshell/test_lock_auth_gate.js
Normal 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
|
||||
21
tests/quickshell/test_lock_projection.js
Normal file
21
tests/quickshell/test_lock_projection.js
Normal 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);
|
||||
Loading…
Reference in a new issue