Harden deployment, authentication, firewall and media storage failure paths
This commit is contained in:
parent
64ede7bd05
commit
7145a35927
35 changed files with 2529 additions and 499 deletions
92
settings/auth.nix
Normal file
92
settings/auth.nix
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Authenticate before creating the desktop; keep its lock separate from the bar.
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
enabled = lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ];
|
||||
theme = config.home-manager.users.fred.xdg.configFile."quickshell/Theme.qml".text;
|
||||
bundle = name: shell: pkgs.linkFarm name [
|
||||
{ name = "shell.qml"; path = shell; }
|
||||
{ name = "AuthView.qml"; path = ./quickshell/AuthView.qml; }
|
||||
{ name = "Theme.qml"; path = pkgs.writeText "Theme.qml" theme; }
|
||||
{ name = "qmldir"; path = pkgs.writeText "qmldir" "singleton Theme 1.0 Theme.qml\nAuthView 1.0 AuthView.qml\n"; }
|
||||
];
|
||||
greeter = bundle "fred-greeter" ./quickshell/Greeter.qml;
|
||||
locker = bundle "fred-lock" ./quickshell/SessionLock.qml;
|
||||
session = pkgs.writeShellScript "hyprland-session" ''
|
||||
exec ${config.programs.hyprland.package}/bin/start-hyprland >/dev/null 2>&1
|
||||
'';
|
||||
lockCommand = pkgs.writeShellApplication {
|
||||
name = "fred-lock";
|
||||
runtimeInputs = [ pkgs.systemd pkgs.coreutils pkgs.quickshell ];
|
||||
text = builtins.readFile ../scripts/lock-session.sh;
|
||||
};
|
||||
in {
|
||||
config = lib.mkIf enabled {
|
||||
system.build.auth = pkgs.linkFarm "fred-auth" [
|
||||
{ name = "greeter"; path = greeter; }
|
||||
{ name = "lock"; path = locker; }
|
||||
{ name = "bin"; path = "${lockCommand}/bin"; }
|
||||
];
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
greeterManagesPlymouth = true;
|
||||
settings.default_session = {
|
||||
user = "greeter";
|
||||
command = toString (pkgs.writeShellScript "fred-greeter" ''
|
||||
export FRED_SESSION_COMMAND=${session}
|
||||
export FRED_GREETER=1
|
||||
export QML2_IMPORT_PATH=${pkgs.qt6.qt5compat}/lib/qt-6/qml
|
||||
export XKB_DEFAULT_LAYOUT=gb,no
|
||||
export XKB_DEFAULT_OPTIONS=${lib.escapeShellArg ("grp:win_space_toggle" + lib.optionalString (config.networking.hostName == "FredOS-Gaming") ",altwin:swap_lalt_lwin")}
|
||||
exec ${pkgs.dbus}/bin/dbus-run-session -- ${pkgs.cage}/bin/cage -s -- ${pkgs.quickshell}/bin/qs -p ${greeter}
|
||||
'');
|
||||
};
|
||||
};
|
||||
security.pam.services.greetd.allowNullPassword = lib.mkForce false;
|
||||
security.pam.services.fred-lock = { allowNullPassword = false; };
|
||||
environment.systemPackages = [ lockCommand ];
|
||||
|
||||
home-manager.users.fred.systemd.user.services.quickshell-lock = {
|
||||
Unit = {
|
||||
Description = "Independent Quickshell session lock";
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
After = [ "graphical-session.target" ];
|
||||
StartLimitIntervalSec = 0;
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${pkgs.quickshell}/bin/qs -p ${locker}";
|
||||
Environment = [ "QML2_IMPORT_PATH=${pkgs.qt6.qt5compat}/lib/qt-6/qml" ];
|
||||
Restart = "always";
|
||||
RestartSec = 1;
|
||||
TimeoutStartSec = 5;
|
||||
};
|
||||
# No WantedBy: greetd already authenticated the new desktop session.
|
||||
};
|
||||
|
||||
systemd.services.lock-before-sleep = {
|
||||
description = "Require a confirmed session lock before sleep";
|
||||
before = [ "sleep.target" ];
|
||||
requiredBy = [ "sleep.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "fred";
|
||||
TimeoutStartSec = "20s";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
export HOME=/home/fred
|
||||
export XDG_RUNTIME_DIR=/run/user/$(${pkgs.coreutils}/bin/id -u)
|
||||
# A greeter has no desktop to lock. Query logind, not a potentially
|
||||
# stale user target, and fail closed if querying logind fails.
|
||||
sessions=$(${pkgs.systemd}/bin/loginctl list-sessions --no-legend --no-pager)
|
||||
fred_uid=$(${pkgs.coreutils}/bin/id -u)
|
||||
while read -r session uid _; do
|
||||
[ "$uid" = "$fred_uid" ] || continue
|
||||
type=$(${pkgs.systemd}/bin/loginctl show-session "$session" -p Type --value)
|
||||
if [ "$type" = wayland ] || [ "$type" = x11 ]; then
|
||||
exec ${lockCommand}/bin/fred-lock
|
||||
fi
|
||||
done <<< "$sessions"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -3,28 +3,9 @@
|
|||
{
|
||||
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
|
||||
services.xserver.enable = true;
|
||||
# Autologin straight into Hyprland; the quickshell lockscreen engages
|
||||
# immediately (see Lock.qml), so the lock *is* the login prompt.
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
# We handle the plymouth handoff ourselves below — don't wait for
|
||||
# plymouth-quit-wait (that wait is what dropped boot to a console
|
||||
# flash between splash and Hyprland).
|
||||
greeterManagesPlymouth = true;
|
||||
settings.default_session = {
|
||||
# start-hyprland prints an ASCII logo to the VT, which repaints the
|
||||
# console over the retained splash frame — silence it (Hyprland still
|
||||
# logs to $XDG_RUNTIME_DIR/hypr/*/hyprland.log).
|
||||
command = toString (pkgs.writeShellScript "hyprland-session" ''
|
||||
exec ${config.programs.hyprland.package}/bin/start-hyprland >/dev/null 2>&1
|
||||
'');
|
||||
user = "fred";
|
||||
};
|
||||
};
|
||||
|
||||
# GDM-style splash→session handoff: nothing else quits plymouth at boot;
|
||||
# greetd freezes the last splash frame (--retain-splash) right before it
|
||||
# spawns Hyprland, which then flips straight to the lockscreen.
|
||||
# spawns the isolated greeter compositor.
|
||||
systemd.services.plymouth-quit.wantedBy = lib.mkForce [ ];
|
||||
# ...and plymouth-quit-wait must go too: greetd is Type=idle, so it defers
|
||||
# its ExecStart while any job is pending — and the only pending job is
|
||||
|
|
@ -44,9 +25,7 @@
|
|||
services.flatpak.enable = true;
|
||||
|
||||
# Secret Service for Chromium/Electron credential encryption.
|
||||
# Autologin can't unlock the keyring (no password typed), so greetd only
|
||||
# *starts* the daemon; the quickshell lockscreen authenticates against the
|
||||
# "login" PAM service, where pam_gnome_keyring unlocks it on first unlock.
|
||||
# greetd passes the login password to PAM before starting the session.
|
||||
services.gnome.gnome-keyring.enable = true;
|
||||
security.pam.services.greetd.enableGnomeKeyring = true;
|
||||
security.pam.services.login.enableGnomeKeyring = true;
|
||||
|
|
|
|||
|
|
@ -62,40 +62,6 @@ in
|
|||
libcanberra-gtk3
|
||||
];
|
||||
|
||||
# Lock *before* the machine actually suspends. hypridle's
|
||||
# before_sleep_cmd cannot do this: it spawns the command and releases
|
||||
# its sleep inhibitor in the same breath (journal: "Executing …" then
|
||||
# "Releasing the sleep inhibitor!" on the same second), so the qs
|
||||
# process gets frozen mid-lock and only finishes on resume — the lid
|
||||
# opens showing the pre-suspend desktop for a beat before the lock
|
||||
# paints. A unit ordered Before=sleep.target genuinely blocks the
|
||||
# suspend until it exits. The sleep covers the gap between quickshell
|
||||
# accepting the IPC and the lock surface committing a frame.
|
||||
systemd.services.lock-before-sleep = {
|
||||
description = "Lock the session before suspend";
|
||||
before = [ "sleep.target" ];
|
||||
wantedBy = [ "sleep.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "fred";
|
||||
# XDG_RUNTIME_DIR must be derived at runtime: the %U specifier is the
|
||||
# UID of the *manager* running the unit (0 for system units), not the
|
||||
# one in User=, so it pointed qs at /run/user/0.
|
||||
ExecStart = pkgs.writeShellScript "lock-before-sleep" ''
|
||||
export XDG_RUNTIME_DIR=/run/user/$(${pkgs.coreutils}/bin/id -u)
|
||||
# HOME too: system units don't set it for User=, and qs resolves
|
||||
# the config (and thus the instance to talk to) via ~/.config —
|
||||
# without it: "Could not find "default" config directory".
|
||||
export HOME=/home/fred
|
||||
# --any-display: no WAYLAND_DISPLAY here, and qs skips instances
|
||||
# on other displays without it.
|
||||
${pkgs.quickshell}/bin/qs ipc --any-display call lock lock
|
||||
${pkgs.coreutils}/bin/sleep 1
|
||||
'';
|
||||
TimeoutStartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
home-manager.users.fred = { config, lib, pkgs, inputs, ... }:
|
||||
let
|
||||
c = config.lib.stylix.colors;
|
||||
|
|
@ -325,24 +291,14 @@ in
|
|||
-- Ensure hyprland-session.target starts even if HM's
|
||||
-- dbus-update-activation-environment chain fails upstream.
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
-- greetd's pam leaves gnome-keyring-daemon in half-initialized
|
||||
-- --login mode (autologin = no password, and nothing in a
|
||||
-- non-GNOME session runs the second stage). --start completes
|
||||
-- init so the lockscreen's pam unlock actually reaches it.
|
||||
hl.exec_cmd("${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --start --components=secrets")
|
||||
-- Complete the keyring's PAM login initialization before Spotify
|
||||
-- requests credentials. greetd has already authenticated Fred.
|
||||
hl.exec_cmd("sh -c '${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --start --components=secrets && exec spotify'")
|
||||
-- mako removed; notifications handled by quickshell
|
||||
|
||||
hl.exec_cmd("wl-paste --type text --watch cliphist store")
|
||||
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
||||
hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24")
|
||||
-- Spotify is NOT started here. It asks the Secret Service for its
|
||||
-- credentials the moment it launches, and at this point the
|
||||
-- lockscreen has not been dismissed yet, so the login keyring is
|
||||
-- still locked and it lands on gcr's unlock prompt instead of
|
||||
-- loading. Lock.qml starts it after pam accepts the password
|
||||
-- (pam_gnome_keyring unlocks the keyring in its auth phase). The
|
||||
-- window rule below still swallows it onto special:music, and the
|
||||
-- respawn-on-close rule above is unaffected.
|
||||
-- Scratchpad terminal: parked on special:scratch at
|
||||
-- login so SUPER+Return is always an instant toggle and the shell
|
||||
-- keeps its history/cwd between peeks.
|
||||
|
|
@ -608,8 +564,8 @@ in
|
|||
# (settings/quickshell.nix) via `hyprctl hyprsunset`.
|
||||
services.hyprsunset.enable = true;
|
||||
|
||||
# Lock screen is quickshell's WlSessionLock (settings/quickshell.nix),
|
||||
# triggered via `qs ipc call lock lock`.
|
||||
# The independent Quickshell lock is configured in settings/auth.nix,
|
||||
# triggered through the confirmed-lock helper.
|
||||
services.hypridle = lib.mkIf isMacbook (
|
||||
let
|
||||
# Skip the action if any MPRIS player is playing — covers windowed
|
||||
|
|
@ -620,7 +576,7 @@ in
|
|||
enable = true;
|
||||
settings = {
|
||||
general = {
|
||||
lock_cmd = "qs ipc call lock lock";
|
||||
lock_cmd = "fred-lock";
|
||||
# No before_sleep_cmd: hypridle does not wait for it before
|
||||
# dropping its sleep inhibitor, so the lock lands after resume.
|
||||
# systemd.services.lock-before-sleep handles suspend instead.
|
||||
|
|
|
|||
|
|
@ -476,7 +476,6 @@ in
|
|||
singleton Commands 1.0 Commands.qml
|
||||
singleton MonoIcons 1.0 MonoIcons.qml
|
||||
Bar 1.0 Bar.qml
|
||||
Lock 1.0 Lock.qml
|
||||
Cheatsheet 1.0 Cheatsheet.qml
|
||||
'';
|
||||
};
|
||||
|
|
@ -501,7 +500,10 @@ in
|
|||
// reboot; every colour below is a binding on `pal`, so
|
||||
// writing this file re-tints the entire shell in place.
|
||||
property FileView wallFile: FileView {
|
||||
path: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json"
|
||||
// The greeter uses the declared default wallpaper and
|
||||
// never reads or writes a user's runtime theme state.
|
||||
path: Quickshell.env("FRED_GREETER") === "1" ? ""
|
||||
: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json"
|
||||
printErrors: false
|
||||
// Synchronous: the palette load chains off this, and an
|
||||
// async hop here means one frame painted in the wrong
|
||||
|
|
@ -511,7 +513,7 @@ in
|
|||
id: wallState
|
||||
property string name: "${defaultWall}"
|
||||
}
|
||||
onLoadFailed: writeAdapter()
|
||||
onLoadFailed: if (path !== "") writeAdapter()
|
||||
}
|
||||
|
||||
// Unknown name (wallpaper deleted from the repo since it was
|
||||
|
|
@ -715,324 +717,6 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
"quickshell/Lock.qml" = {
|
||||
onChange = qsRestart;
|
||||
text = ''
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Pam
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Services.Mpris
|
||||
import QtQuick
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
// ext-session-lock screen, replacing hyprlock. Set `locked: true`
|
||||
// and the compositor hands every screen to a LockSurface below
|
||||
// until PAM says otherwise.
|
||||
//
|
||||
// PAM and state live in a Scope OUTSIDE WlSessionLock: its default
|
||||
// property is the surface *component*, so a PamContext declared
|
||||
// inside it is never instantiated — `pam` didn't resolve and
|
||||
// submit() threw, which is why enter/the arrow did nothing.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
property alias locked: lock.locked
|
||||
property string password: ""
|
||||
property bool failed: false
|
||||
// True only for the once-per-session lock below, so a later
|
||||
// manual lock/unlock does not re-launch spotify.
|
||||
property bool bootLock: false
|
||||
|
||||
function submit(pw) {
|
||||
if (pam.active || pw === "") return;
|
||||
failed = false;
|
||||
password = pw;
|
||||
pam.start();
|
||||
}
|
||||
|
||||
// greetd autologs us in, so lock as soon as the shell comes up:
|
||||
// the lockscreen is the login prompt. mkdir is the atomic
|
||||
// once-per-session test — XDG_RUNTIME_DIR dies with the
|
||||
// session, so quickshell restarts (rebuilds) don't re-lock.
|
||||
Process {
|
||||
running: true
|
||||
command: ["mkdir", Quickshell.env("XDG_RUNTIME_DIR") + "/qs-boot-lock"]
|
||||
onExited: code => { if (code === 0) { root.bootLock = true; lock.locked = true; } }
|
||||
}
|
||||
|
||||
// Nothing here unlocks the keyring — pam_gnome_keyring already
|
||||
// did it during the *auth* phase. gkr-pam-module.c's
|
||||
// pam_sm_authenticate calls unlock_keyring() directly and logs
|
||||
// "gkr-pam: unlocked login keyring"; the session phase is only
|
||||
// its fallback for when that fails. So PamContext's auth-only
|
||||
// run has always been enough, and an explicit
|
||||
// gnome-keyring-daemon --replace --unlock here was both
|
||||
// redundant and a trap: the replacement daemon inherits
|
||||
// quickshell's stdout, holds the pipe open for the life of the
|
||||
// session, and the Process therefore never reports exiting.
|
||||
//
|
||||
// The only real problem was ordering. Spotify asks the Secret
|
||||
// Service the moment it launches, and from hyprland.start that
|
||||
// is before the lockscreen has been dismissed — keyring still
|
||||
// locked, so it lands on gcr's unlock prompt. Start it here,
|
||||
// after PAM has accepted the password, instead.
|
||||
Process {
|
||||
id: spotifyStart
|
||||
// Via hyprland, not as a child of quickshell: a shell
|
||||
// restart on rebuild must not take spotify down with it.
|
||||
command: ["hyprctl", "dispatch", "exec", "spotify"]
|
||||
}
|
||||
|
||||
PamContext {
|
||||
id: pam
|
||||
// NixOS ships a "login" pam service; pam_unix verifies the
|
||||
// user's own password via the unix_chkpwd setuid wrapper.
|
||||
config: "login"
|
||||
onPamMessage: {
|
||||
if (this.responseRequired) this.respond(root.password);
|
||||
}
|
||||
onCompleted: result => {
|
||||
if (result === PamResult.Success) {
|
||||
lock.locked = false;
|
||||
// Boot lock only: a later manual lock/unlock must
|
||||
// not spawn a second spotify.
|
||||
if (root.bootLock) {
|
||||
root.bootLock = false;
|
||||
spotifyStart.running = true;
|
||||
}
|
||||
} else root.failed = true;
|
||||
root.password = "";
|
||||
}
|
||||
}
|
||||
|
||||
WlSessionLock {
|
||||
id: lock
|
||||
|
||||
WlSessionLockSurface {
|
||||
id: surface
|
||||
color: "black"
|
||||
|
||||
Image {
|
||||
id: wall
|
||||
anchors.fill: parent
|
||||
source: "file://" + Theme.wallImage
|
||||
// cache:false — soft reload (qs ipc reload) keeps the process
|
||||
// alive, and the QML pixmap cache is keyed by URL, so a new
|
||||
// wallpaper behind the same path never shows without this.
|
||||
cache: false
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
visible: false
|
||||
}
|
||||
FastBlur {
|
||||
anchors.fill: wall
|
||||
source: wall
|
||||
radius: 20
|
||||
}
|
||||
// Lighter blur means less natural contrast, so the scrim
|
||||
// is a left-weighted gradient instead of a flat 0.4 wash:
|
||||
// dark under the text column, near-clear on the right.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "#b0000000" }
|
||||
GradientStop { position: 0.55; color: "#59000000" }
|
||||
GradientStop { position: 1.0; color: "#33000000" }
|
||||
}
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: lockClock
|
||||
precision: SystemClock.Seconds
|
||||
}
|
||||
|
||||
// Lower-left, not centred: clock/date/password stack left
|
||||
// aligned against a 10% margin, sitting below the middle.
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Math.round(parent.width * 0.10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: Math.round(parent.height * 0.14)
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
|
||||
color: Theme.base05
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 104
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
|
||||
color: Theme.base04
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 18
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
${lib.optionalString isMacbook ''
|
||||
// Power. UPower.displayDevice is the same DBus source
|
||||
// the bar reads — no polling, no /sys parsing.
|
||||
Row {
|
||||
id: lockBatt
|
||||
spacing: 8
|
||||
property var dev: UPower.displayDevice
|
||||
property int level: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
|
||||
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
|
||||
property string remaining: {
|
||||
if (!dev) return "";
|
||||
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
|
||||
if (!secs || secs <= 0) return "";
|
||||
return Math.floor(secs / 3600) + "h " + Math.round((secs % 3600) / 60) + "m";
|
||||
}
|
||||
visible: dev && dev.ready
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.charging ? "battery-charging"
|
||||
: lockBatt.level >= 75 ? "battery-full"
|
||||
: lockBatt.level >= 50 ? "battery-medium"
|
||||
: lockBatt.level >= 20 ? "battery-low"
|
||||
: "battery"
|
||||
font.family: Theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: lockBatt.level <= 15 ? Theme.err
|
||||
: lockBatt.level <= 30 ? Theme.warn : Theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.level + "%" + (lockBatt.remaining === "" ? ""
|
||||
: " · " + lockBatt.remaining + (lockBatt.charging ? " to full" : " left"))
|
||||
color: Theme.base04
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
''}
|
||||
|
||||
// Now playing. Mpris is a global service, so this shows
|
||||
// whatever kept playing into the lock — nothing is woken.
|
||||
Row {
|
||||
id: lockMedia
|
||||
spacing: 8
|
||||
property var player: Mpris.players.values.filter(p => p.trackTitle)[0]
|
||||
visible: !!player
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "music"
|
||||
font.family: Theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: Theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 380
|
||||
elide: Text.ElideRight
|
||||
text: lockMedia.player
|
||||
? lockMedia.player.trackTitle + (lockMedia.player.trackArtist
|
||||
? " · " + lockMedia.player.trackArtist : "")
|
||||
: ""
|
||||
color: Theme.base04
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
|
||||
// Password pill: dots for typed chars; border tracks
|
||||
// state (accent while checking, red on failure).
|
||||
Rectangle {
|
||||
width: 300
|
||||
height: 46
|
||||
radius: Theme.radiusCard
|
||||
color: Theme.cardBg
|
||||
border.width: Theme.borderWidth
|
||||
border.color: pam.active ? Theme.accent
|
||||
: root.failed ? Theme.err
|
||||
: Theme.base03
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.animFade } }
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: Math.min(pwInput.text.length, 24)
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: Theme.base05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: pwInput.text === "" && root.failed
|
||||
text: "wrong password"
|
||||
color: Theme.err
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: pwInput
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
enabled: !pam.active
|
||||
echoMode: TextInput.Password
|
||||
focus: true
|
||||
function go() {
|
||||
root.submit(text);
|
||||
text = "";
|
||||
}
|
||||
// Explicit key handlers: accepted() doesn't
|
||||
// fire reliably on the lock surface.
|
||||
Keys.onReturnPressed: go()
|
||||
Keys.onEnterPressed: go()
|
||||
onAccepted: go()
|
||||
Keys.onEscapePressed: text = ""
|
||||
}
|
||||
|
||||
// Visible confirm button (after TextInput: on top)
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 32; height: 32; radius: 16
|
||||
color: confirmMa.containsMouse ? Theme.base02 : "transparent"
|
||||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||||
visible: pwInput.text.length > 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "arrow-right"
|
||||
font.family: Theme.iconFont
|
||||
font.pixelSize: 18
|
||||
color: Theme.accent
|
||||
}
|
||||
MouseArea {
|
||||
id: confirmMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pwInput.go()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: pwInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
"quickshell/Cheatsheet.qml" = {
|
||||
onChange = qsRestart;
|
||||
text = ''
|
||||
|
|
@ -1265,16 +949,11 @@ in
|
|||
function reload(): void { Quickshell.reload(false); }
|
||||
}
|
||||
|
||||
// Session lock (replaces hyprlock). `qs ipc call lock lock`
|
||||
// from hypridle / loginctl lock-session; the session menu
|
||||
// calls root.lock() directly. Idempotent while locked.
|
||||
Lock { id: lockScreen }
|
||||
function lock() { lockScreen.locked = true; }
|
||||
IpcHandler {
|
||||
target: "lock"
|
||||
function lock(): void { root.lock(); }
|
||||
// Escape hatch from a VT: qs ipc call lock unlock
|
||||
function unlock(): void { lockScreen.locked = false; }
|
||||
// The lock runs independently of the bar and its reloads.
|
||||
function lock() { lockRequest.running = true; }
|
||||
Process {
|
||||
id: lockRequest
|
||||
command: ["fred-lock"]
|
||||
}
|
||||
|
||||
// Screenshot pin: the Shift+Super+S keybind brackets hyprshot
|
||||
|
|
|
|||
226
settings/quickshell/AuthView.qml
Normal file
226
settings/quickshell/AuthView.qml
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Services.Mpris
|
||||
import QtQuick
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
// Shared appearance; authentication belongs to the greeter or session lock.
|
||||
Item {
|
||||
id: view
|
||||
required property var theme
|
||||
property bool busy: false
|
||||
property string errorMessage: ""
|
||||
signal submitted(string password)
|
||||
|
||||
Image {
|
||||
id: wall
|
||||
anchors.fill: parent
|
||||
source: "file://" + view.theme.wallImage
|
||||
// cache:false — soft reload (qs ipc reload) keeps the process
|
||||
// alive, and the QML pixmap cache is keyed by URL, so a new
|
||||
// wallpaper behind the same path never shows without this.
|
||||
cache: false
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
visible: false
|
||||
}
|
||||
FastBlur {
|
||||
anchors.fill: wall
|
||||
source: wall
|
||||
radius: 20
|
||||
}
|
||||
// Lighter blur means less natural contrast, so the scrim
|
||||
// is a left-weighted gradient instead of a flat 0.4 wash:
|
||||
// dark under the text column, near-clear on the right.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "#b0000000" }
|
||||
GradientStop { position: 0.55; color: "#59000000" }
|
||||
GradientStop { position: 1.0; color: "#33000000" }
|
||||
}
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
id: lockClock
|
||||
precision: SystemClock.Seconds
|
||||
}
|
||||
|
||||
// Lower-left, not centred: clock/date/password stack left
|
||||
// aligned against a 10% margin, sitting below the middle.
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Math.round(parent.width * 0.10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: Math.round(parent.height * 0.14)
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
|
||||
color: view.theme.base05
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 104
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
|
||||
color: view.theme.base04
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 18
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
// Power. UPower.displayDevice is the same DBus source
|
||||
// the bar reads — no polling, no /sys parsing.
|
||||
Row {
|
||||
id: lockBatt
|
||||
spacing: 8
|
||||
property var dev: UPower.displayDevice
|
||||
property int level: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
|
||||
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
|
||||
property string remaining: {
|
||||
if (!dev) return "";
|
||||
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
|
||||
if (!secs || secs <= 0) return "";
|
||||
return Math.floor(secs / 3600) + "h " + Math.round((secs % 3600) / 60) + "m";
|
||||
}
|
||||
visible: dev && dev.ready
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.charging ? "battery-charging"
|
||||
: lockBatt.level >= 75 ? "battery-full"
|
||||
: lockBatt.level >= 50 ? "battery-medium"
|
||||
: lockBatt.level >= 20 ? "battery-low"
|
||||
: "battery"
|
||||
font.family: view.theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: lockBatt.level <= 15 ? view.theme.err
|
||||
: lockBatt.level <= 30 ? view.theme.warn : view.theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: lockBatt.level + "%" + (lockBatt.remaining === "" ? ""
|
||||
: " · " + lockBatt.remaining + (lockBatt.charging ? " to full" : " left"))
|
||||
color: view.theme.base04
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
// Now playing. Mpris is a global service, so this shows
|
||||
// whatever kept playing into the lock — nothing is woken.
|
||||
Row {
|
||||
id: lockMedia
|
||||
spacing: 8
|
||||
property var player: Mpris.players.values.filter(p => p.trackTitle)[0]
|
||||
visible: !!player
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "music"
|
||||
font.family: view.theme.iconFont
|
||||
font.pixelSize: 17
|
||||
color: view.theme.base04
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 380
|
||||
elide: Text.ElideRight
|
||||
text: lockMedia.player
|
||||
? lockMedia.player.trackTitle + (lockMedia.player.trackArtist
|
||||
? " · " + lockMedia.player.trackArtist : "")
|
||||
: ""
|
||||
color: view.theme.base04
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 14 }
|
||||
|
||||
|
||||
// Password pill: dots for typed chars; border tracks
|
||||
// state (accent while checking, red on failure).
|
||||
Rectangle {
|
||||
width: 300
|
||||
height: 46
|
||||
radius: view.theme.radiusCard
|
||||
color: view.theme.cardBg
|
||||
border.width: view.theme.borderWidth
|
||||
border.color: view.busy ? view.theme.accent
|
||||
: (view.errorMessage !== "") ? view.theme.err
|
||||
: view.theme.base03
|
||||
Behavior on border.color { ColorAnimation { duration: view.theme.animFade } }
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: Math.min(pwInput.text.length, 24)
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: view.theme.base05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: pwInput.text === "" && (view.errorMessage !== "")
|
||||
text: view.errorMessage
|
||||
color: view.theme.err
|
||||
font.family: view.theme.fontFamily
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: pwInput
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
enabled: !view.busy
|
||||
echoMode: TextInput.Password
|
||||
focus: true
|
||||
function go() {
|
||||
view.submitted(text);
|
||||
text = "";
|
||||
}
|
||||
// Explicit key handlers: accepted() doesn't
|
||||
// fire reliably on the lock surface.
|
||||
Keys.onReturnPressed: go()
|
||||
Keys.onEnterPressed: go()
|
||||
onAccepted: go()
|
||||
Keys.onEscapePressed: text = ""
|
||||
}
|
||||
|
||||
// Visible confirm button (after TextInput: on top)
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 32; height: 32; radius: 16
|
||||
color: confirmMa.containsMouse ? view.theme.base02 : "transparent"
|
||||
Behavior on color { ColorAnimation { duration: view.theme.animFade } }
|
||||
visible: pwInput.text.length > 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "arrow-right"
|
||||
font.family: view.theme.iconFont
|
||||
font.pixelSize: 18
|
||||
color: view.theme.accent
|
||||
}
|
||||
MouseArea {
|
||||
id: confirmMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pwInput.go()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: pwInput.forceActiveFocus()
|
||||
}
|
||||
65
settings/quickshell/Greeter.qml
Normal file
65
settings/quickshell/Greeter.qml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Quickshell
|
||||
import Quickshell.Services.Greetd
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
|
||||
Window {
|
||||
id: root
|
||||
visible: true
|
||||
visibility: Window.FullScreen
|
||||
color: "black"
|
||||
property string password: ""
|
||||
property string errorMessage: ""
|
||||
property bool busy: false
|
||||
Component.onCompleted: Quickshell.watchFiles = false
|
||||
|
||||
function failed(message) {
|
||||
password = "";
|
||||
busy = false;
|
||||
errorMessage = message;
|
||||
}
|
||||
Connections {
|
||||
target: Greetd
|
||||
function onAuthMessage(message, error, responseRequired, echoResponse) {
|
||||
if (responseRequired) {
|
||||
// This greeter supports the configured password-only PAM flow.
|
||||
if (echoResponse || root.password === "") {
|
||||
Greetd.cancelSession();
|
||||
root.failed("Use a text console for this prompt");
|
||||
return;
|
||||
}
|
||||
Greetd.respond(root.password);
|
||||
root.password = "";
|
||||
} else if (error) root.errorMessage = message;
|
||||
}
|
||||
function onAuthFailure(message) { root.failed("Authentication failed"); }
|
||||
function onError(error) { root.failed("Login unavailable; try again"); }
|
||||
function onReadyToLaunch() {
|
||||
root.password = "";
|
||||
// PAM/logind must know this is a graphical session before the
|
||||
// compositor starts, including for the pre-suspend lock check.
|
||||
Greetd.launch([Quickshell.env("FRED_SESSION_COMMAND")], [
|
||||
"XDG_SESSION_TYPE=wayland",
|
||||
"XDG_CURRENT_DESKTOP=Hyprland",
|
||||
"XDG_SESSION_DESKTOP=Hyprland"
|
||||
]);
|
||||
}
|
||||
}
|
||||
AuthView {
|
||||
anchors.fill: parent
|
||||
theme: Theme
|
||||
busy: root.busy
|
||||
errorMessage: root.errorMessage
|
||||
onSubmitted: password => {
|
||||
if (root.busy || password === "") return;
|
||||
if (!Greetd.available) {
|
||||
root.failed("Login service unavailable");
|
||||
return;
|
||||
}
|
||||
root.errorMessage = "";
|
||||
root.password = password;
|
||||
root.busy = true;
|
||||
Greetd.createSession("fred");
|
||||
}
|
||||
}
|
||||
}
|
||||
61
settings/quickshell/SessionLock.qml
Normal file
61
settings/quickshell/SessionLock.qml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Pam
|
||||
import QtQuick
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
property string password: ""
|
||||
property string errorMessage: ""
|
||||
|
||||
// Immutable configuration, independent of bar edits and reloads.
|
||||
Component.onCompleted: Quickshell.watchFiles = false
|
||||
function requestLock() {
|
||||
Theme.wallFile.reload();
|
||||
sessionLock.locked = true;
|
||||
}
|
||||
IpcHandler {
|
||||
target: "lock"
|
||||
function lock(): void { root.requestLock(); }
|
||||
function secure(): bool { return sessionLock.secure; }
|
||||
}
|
||||
PamContext {
|
||||
id: pam
|
||||
config: "fred-lock"
|
||||
onPamMessage: {
|
||||
if (responseRequired) {
|
||||
respond(root.password);
|
||||
root.password = "";
|
||||
}
|
||||
}
|
||||
onCompleted: result => {
|
||||
root.password = "";
|
||||
if (result === PamResult.Success) sessionLock.locked = false;
|
||||
else root.errorMessage = "Authentication failed";
|
||||
}
|
||||
}
|
||||
WlSessionLock {
|
||||
id: sessionLock
|
||||
// Start only on demand, but always relock after a process crash.
|
||||
locked: true
|
||||
WlSessionLockSurface {
|
||||
color: "black"
|
||||
AuthView {
|
||||
anchors.fill: parent
|
||||
theme: Theme
|
||||
busy: pam.active
|
||||
errorMessage: root.errorMessage
|
||||
onSubmitted: password => {
|
||||
if (pam.active || password === "") return;
|
||||
root.errorMessage = "";
|
||||
root.password = password;
|
||||
if (!pam.start()) {
|
||||
root.password = "";
|
||||
root.errorMessage = "Authentication unavailable";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue