mirror of
https://github.com/Sn3akyy1/lucid.git
synced 2026-11-07 15:36:04 +03:00
ff23a1ef45
Route Discord clients to their launched special workspace and refine fingerprint status prompts and session-listening behavior.
929 lines
31 KiB
QML
929 lines
31 KiB
QML
pragma Singleton
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Bluetooth
|
|
import Quickshell.Networking
|
|
import Quickshell.Services.Pam
|
|
import Quickshell.Services.UPower
|
|
import qs
|
|
|
|
// the lock session's brain: pam, the attempt ledger, the keyboard's locks and
|
|
// the two faces the surface can wear. lucidlock/ only draws what it reads here
|
|
Singleton {
|
|
id: root
|
|
|
|
// ── session ────────────────────────────────────────────────────────────
|
|
property bool locked: false
|
|
// the surface is playing its exit and must not accept anything more
|
|
property bool leaving: false
|
|
// glance shows the day; focus shows the field. typing crosses over
|
|
property bool focused: false
|
|
// ── surfaces ───────────────────────────────────────────────────────────
|
|
// solid m3 containers, so a busy wallpaper never reads through a card
|
|
readonly property color card: Theme.surfaceContainer
|
|
readonly property color cardHigh: Theme.surfaceHighest
|
|
readonly property color hairline: Theme.alpha(Theme.outline, 0.45)
|
|
|
|
// ── who is being asked ─────────────────────────────────────────────────
|
|
// read lazily: touching Users spawns its probe, and nothing wants that
|
|
// at shell start — only once a lock is actually up
|
|
readonly property var account: root.locked ? Users.me : null
|
|
readonly property string userName: root.account ? root.account.name : (Quickshell.env("USER") || "")
|
|
readonly property string displayName: root.account ? Users.displayName(root.account) : root.userName
|
|
readonly property string initials: root.account ? Users.initials(root.account) : (root.userName !== "" ? root.userName.substring(0, 2).toUpperCase() : "?")
|
|
readonly property string avatar: root.account ? Users.avatarUrl(root.account) : ""
|
|
|
|
// ── pam ────────────────────────────────────────────────────────────────
|
|
// /etc/pam.d/login is the one service every distro ships for a local seat
|
|
property string pamConfig: "login"
|
|
// idle | prompting | checking | failed | error | granted
|
|
property string phase: "idle"
|
|
// pam asks in its own words once it wants anything but the password
|
|
property string prompt: ""
|
|
property bool secret: true
|
|
// pam's own last sentence, which is better copy than anything we invent
|
|
property string pamText: ""
|
|
property int attempts: 0
|
|
property bool fingerOn: false
|
|
property string fingerText: ""
|
|
property bool fingerFailed: false
|
|
// faillock's defaults, replaced by its config when that sets them
|
|
property int denyAfter: 3
|
|
property int unlockAfter: 600
|
|
|
|
// seconds left on a faillock lockout, counted down locally
|
|
property int lockoutLeft: 0
|
|
readonly property bool lockedOut: root.lockoutLeft > 0
|
|
readonly property int triesLeft: Math.max(0, root.denyAfter - root.attempts)
|
|
readonly property bool busy: root.phase === "checking"
|
|
readonly property bool granted: root.phase === "granted"
|
|
// nothing may be typed while pam owns the turn or the account is barred
|
|
readonly property bool acceptsInput: !root.busy && !root.granted && !root.leaving && !root.lockedOut
|
|
|
|
// ── keyboard ───────────────────────────────────────────────────────────
|
|
property bool capsLock: false
|
|
property string layout: ""
|
|
// "English (US)" is too much for a 20px chip; keep the short half
|
|
readonly property string layoutShort: {
|
|
if (root.layout === "")
|
|
return "";
|
|
|
|
var m = /\(([^)]+)\)/.exec(root.layout);
|
|
var t = m ? m[1] : root.layout.split(/[\s,]+/)[0];
|
|
return t.substring(0, 6).toUpperCase();
|
|
}
|
|
|
|
// ── the one line under the field ───────────────────────────────────────
|
|
// kind drives its colour: error | good | info | none
|
|
readonly property string statusKind: {
|
|
if (root.granted)
|
|
return "good";
|
|
|
|
if (root.lockedOut || root.phase === "failed" || root.phase === "error")
|
|
return "error";
|
|
|
|
if (root.phase === "checking" || root.phase === "prompting")
|
|
return "info";
|
|
|
|
if (root.fingerOn)
|
|
return root.fingerFailed ? "error" : "info";
|
|
|
|
return "none";
|
|
}
|
|
readonly property string statusText: {
|
|
if (root.granted)
|
|
return "Welcome back";
|
|
|
|
if (root.lockedOut)
|
|
return "Too many attempts — try again in " + root.humanSecs(root.lockoutLeft);
|
|
|
|
if (root.phase === "checking")
|
|
return "Checking…";
|
|
|
|
if (root.phase === "error")
|
|
return root.pamText !== "" ? root.pamText : "Authentication is unavailable right now";
|
|
|
|
if (root.phase === "failed") {
|
|
if (root.pamText !== "")
|
|
return root.pamText;
|
|
|
|
if (root.triesLeft === 1)
|
|
return "Incorrect password — one try left";
|
|
|
|
if (root.triesLeft > 1)
|
|
return "Incorrect password — " + root.triesLeft + " tries left";
|
|
|
|
return "Incorrect password";
|
|
}
|
|
if (root.phase === "prompting" && root.prompt !== "")
|
|
return root.prompt;
|
|
|
|
if (root.fingerOn)
|
|
return root.fingerFailed && root.fingerText !== "" ? root.fingerText : "Touch the fingerprint sensor or type your password";
|
|
|
|
return "";
|
|
}
|
|
|
|
function humanSecs(s) {
|
|
if (s >= 90)
|
|
return Math.round(s / 60) + " minutes";
|
|
|
|
if (s > 45)
|
|
return "a minute";
|
|
|
|
return Math.max(1, s) + " seconds";
|
|
}
|
|
|
|
// ── session control ────────────────────────────────────────────────────
|
|
// settle: ms for the menu that asked to shut before the desktop is photographed
|
|
function lock(settle) {
|
|
if (root.locked || root.arming)
|
|
return ;
|
|
|
|
root.arming = true;
|
|
root.captures = ({
|
|
});
|
|
root.seeThrough = false;
|
|
settleWait.interval = Math.max(1, settle || 0);
|
|
settleWait.restart();
|
|
}
|
|
|
|
function lockNow() {
|
|
root.arming = false;
|
|
root.reset();
|
|
root.leaving = false;
|
|
root.focused = false;
|
|
root.lockedAt = Date.now();
|
|
root.locked = true;
|
|
Users.probe();
|
|
root.startFinger();
|
|
}
|
|
|
|
// ── the desktop the lock grows out of ──────────────────────────────────
|
|
// screencopy only sees the lock once it is up, so the desktop is caught first
|
|
property bool arming: false
|
|
// screen name -> file url
|
|
property var captures: ({
|
|
})
|
|
// a surface built long after this (a reload) must not replay the dive
|
|
property real lockedAt: 0
|
|
// session_lock_xray is on, so an unlock can end see-through. only after a grant
|
|
property bool seeThrough: false
|
|
readonly property string captureDir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"
|
|
|
|
function captureFor(screen) {
|
|
return screen ? (root.captures[screen.name] || "") : "";
|
|
}
|
|
|
|
Timer {
|
|
id: settleWait
|
|
|
|
onTriggered: {
|
|
var names = [];
|
|
for (var i = 0; i < Quickshell.screens.length; i++)
|
|
names.push(Quickshell.screens[i].name);
|
|
|
|
capture.stamp = Date.now();
|
|
capture.command = ["sh", "-c", "umask 077; d=$1; s=$2; shift 2; for o; do (timeout 1 grim -t ppm -o \"$o\" \"$d/lucid-lock-$o-$s.ppm\" 2>/dev/null && echo \"$o\") & done; wait", "sh", root.captureDir, String(capture.stamp)].concat(names);
|
|
capture.running = true;
|
|
captureCap.restart();
|
|
}
|
|
}
|
|
|
|
// a screen that is off never hands over a frame; lock without it
|
|
Timer {
|
|
id: captureCap
|
|
|
|
interval: 260
|
|
onTriggered: root.armed(true)
|
|
}
|
|
|
|
Process {
|
|
id: capture
|
|
|
|
property real stamp: 0
|
|
|
|
// the grims hold stdout too, so its end is the end of all of them
|
|
stdout: StdioCollector {
|
|
id: captured
|
|
|
|
onStreamFinished: root.armed(false)
|
|
}
|
|
|
|
}
|
|
|
|
function armed(timedOut) {
|
|
if (!root.arming)
|
|
return ;
|
|
|
|
captureCap.stop();
|
|
var map = {
|
|
};
|
|
if (timedOut) {
|
|
capture.running = false;
|
|
} else {
|
|
var lines = captured.text.split("\n");
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var o = lines[i].trim();
|
|
if (o !== "")
|
|
map[o] = "file://" + root.captureDir + "/lucid-lock-" + o + "-" + capture.stamp + ".ppm";
|
|
|
|
}
|
|
}
|
|
root.captures = map;
|
|
root.lockNow();
|
|
}
|
|
|
|
function forgetCaptures() {
|
|
root.captures = ({
|
|
});
|
|
Quickshell.execDetached(["sh", "-c", "rm -f \"$1\"/lucid-lock-*.ppm", "sh", root.captureDir]);
|
|
}
|
|
|
|
// the old value is kept inside hyprland; a shell that died mid-unlock restores it at start
|
|
Process {
|
|
id: xrayOn
|
|
|
|
command: ["hyprctl", "eval", "if LucidLockXray == nil then local ok, v = pcall(hl.get_config, 'misc.session_lock_xray') LucidLockXray = ok and v or false end hl.config({ ['misc.session_lock_xray'] = true })"]
|
|
onExited: (code) => {
|
|
root.seeThrough = code === 0 && root.leaving;
|
|
}
|
|
}
|
|
|
|
function restoreXray() {
|
|
Quickshell.execDetached(["hyprctl", "eval", "if LucidLockXray ~= nil then hl.config({ ['misc.session_lock_xray'] = LucidLockXray }) LucidLockXray = nil end"]);
|
|
}
|
|
|
|
// after the surface is gone: xray off under a see-through lock is a black frame
|
|
Timer {
|
|
id: xrayOff
|
|
|
|
interval: 400
|
|
onTriggered: root.restoreXray()
|
|
}
|
|
|
|
onLeavingChanged: {
|
|
if (root.leaving && root.locked)
|
|
xrayOn.running = true;
|
|
|
|
}
|
|
|
|
// if the surface never reports back — it failed to draw, or the screen it
|
|
// was on went away — let go anyway rather than stranding the session
|
|
Timer {
|
|
id: deadman
|
|
|
|
running: root.leaving
|
|
interval: Math.max(2000, Theme.ms(2000))
|
|
onTriggered: {
|
|
if (root.locked)
|
|
root.release();
|
|
|
|
}
|
|
}
|
|
|
|
// the surface calls this once its exit animation has played out
|
|
// seeThrough stays set until the next lock, or the last clear frame turns black
|
|
function release() {
|
|
root.locked = false;
|
|
root.leaving = false;
|
|
root.focused = false;
|
|
root.reset();
|
|
root.forgetCaptures();
|
|
xrayOff.restart();
|
|
}
|
|
|
|
// every unlock asked for from outside: the ipc, logind. asking twice, or
|
|
// asking when nothing is locked, must not strand `leaving` set
|
|
function requestUnlock() {
|
|
if (root.arming) {
|
|
root.arming = false;
|
|
settleWait.stop();
|
|
captureCap.stop();
|
|
capture.running = false;
|
|
root.forgetCaptures();
|
|
return ;
|
|
}
|
|
if (root.locked && !root.leaving)
|
|
root.leaving = true;
|
|
|
|
}
|
|
|
|
function reset() {
|
|
if (pam.active)
|
|
pam.abort();
|
|
|
|
root.stopFinger();
|
|
root.phase = "idle";
|
|
root.prompt = "";
|
|
root.secret = true;
|
|
root.pamText = "";
|
|
root.attempts = 0;
|
|
root.pending = "";
|
|
root.lockoutLeft = 0;
|
|
}
|
|
|
|
// typing or clicking anywhere crosses from glance into the field
|
|
function engage() {
|
|
if (!root.focused)
|
|
root.focused = true;
|
|
|
|
idleBack.restart();
|
|
}
|
|
|
|
function disengage() {
|
|
root.focused = false;
|
|
idleBack.stop();
|
|
if (root.phase === "failed" || root.phase === "error")
|
|
root.phase = "idle";
|
|
|
|
}
|
|
|
|
// ── authentication ─────────────────────────────────────────────────────
|
|
property string pending: ""
|
|
|
|
function submit(text) {
|
|
if (!root.acceptsInput || text.length === 0)
|
|
return ;
|
|
|
|
root.pamText = "";
|
|
root.phase = "checking";
|
|
root.pending = text;
|
|
if (pam.active) {
|
|
// pam is mid-conversation and was waiting on us
|
|
root.pending = "";
|
|
pam.respond(text);
|
|
return ;
|
|
}
|
|
if (!pam.start()) {
|
|
root.pending = "";
|
|
root.phase = "error";
|
|
root.pamText = "Cannot reach PAM (/etc/pam.d/" + root.pamConfig + ")";
|
|
} else {
|
|
authTimeout.restart();
|
|
}
|
|
}
|
|
|
|
function fail(text) {
|
|
authTimeout.stop();
|
|
root.attempts += 1;
|
|
root.pamText = text || "";
|
|
root.phase = "failed";
|
|
root.focused = true;
|
|
failed();
|
|
faillockProc.running = true;
|
|
}
|
|
|
|
function grant() {
|
|
authTimeout.stop();
|
|
root.stopFinger();
|
|
if (pam.active)
|
|
pam.abort();
|
|
|
|
root.pending = "";
|
|
root.phase = "granted";
|
|
root.attempts = 0;
|
|
// let the lock be seen opening; the surface leaves a beat later
|
|
graceOut.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: graceOut
|
|
|
|
interval: 520
|
|
onTriggered: root.leaving = true
|
|
}
|
|
|
|
signal failed()
|
|
|
|
PamContext {
|
|
id: pam
|
|
|
|
config: root.pamConfig
|
|
onPamMessage: {
|
|
if (pam.responseRequired) {
|
|
if (root.pending !== "") {
|
|
var p = root.pending;
|
|
root.pending = "";
|
|
root.phase = "checking";
|
|
pam.respond(p);
|
|
} else {
|
|
// a second question: pam wants something we were not holding
|
|
authTimeout.stop();
|
|
root.prompt = pam.message.replace(/:\s*$/, "");
|
|
root.secret = !pam.responseVisible;
|
|
root.phase = "prompting";
|
|
root.focused = true;
|
|
}
|
|
} else if (pam.message !== "") {
|
|
// an aside, not a question — usually the faillock warning
|
|
root.pamText = pam.message;
|
|
}
|
|
}
|
|
onCompleted: (result) => {
|
|
root.pending = "";
|
|
if (result === PamResult.Success)
|
|
root.grant();
|
|
else if (result === PamResult.MaxTries)
|
|
root.fail("Too many attempts — the account is locked");
|
|
else if (result === PamResult.Error)
|
|
root.error("PAM could not complete the check");
|
|
else
|
|
root.fail(root.pamText);
|
|
}
|
|
onError: (e) => root.error(PamError.toString(e) === "StartFailed" ? "PAM refused to start a session" : "PAM failed: " + PamError.toString(e))
|
|
}
|
|
|
|
function error(text) {
|
|
authTimeout.stop();
|
|
root.pamText = text;
|
|
root.phase = "error";
|
|
root.focused = true;
|
|
failed();
|
|
}
|
|
|
|
// ── fingerprint ────────────────────────────────────────────────────────
|
|
// pam_fprintd fails without a word when the reader is busy (a resume,
|
|
// another prompt holding it) or missing, so those retries back off
|
|
property bool fingerHeard: false
|
|
property int fingerQuiet: 0
|
|
|
|
function startFinger() {
|
|
if (!root.locked || root.leaving || root.granted || fprint.active)
|
|
return ;
|
|
|
|
root.fingerHeard = false;
|
|
root.fingerOn = fprint.start();
|
|
if (!root.fingerOn)
|
|
root.retryFinger();
|
|
|
|
}
|
|
|
|
function stopFinger() {
|
|
fingerRetry.stop();
|
|
root.fingerQuiet = 0;
|
|
root.fingerOn = false;
|
|
root.fingerText = "";
|
|
root.fingerFailed = false;
|
|
if (fprint.active)
|
|
fprint.abort();
|
|
|
|
}
|
|
|
|
PamContext {
|
|
id: fprint
|
|
|
|
configDirectory: Quickshell.shellPath("pam")
|
|
config: "fingerprint"
|
|
onPamMessage: {
|
|
if (fprint.message === "")
|
|
return ;
|
|
|
|
root.fingerHeard = true;
|
|
root.fingerText = fprint.message;
|
|
root.fingerFailed = fprint.messageIsError;
|
|
}
|
|
onCompleted: (result) => {
|
|
if (result === PamResult.Success) {
|
|
root.grant();
|
|
return ;
|
|
}
|
|
root.retryFinger();
|
|
}
|
|
onError: root.retryFinger()
|
|
}
|
|
|
|
function retryFinger() {
|
|
root.fingerQuiet = root.fingerHeard ? 0 : root.fingerQuiet + 1;
|
|
if (root.fingerQuiet > 0)
|
|
root.fingerOn = false;
|
|
|
|
fingerRetry.interval = Math.min(60000, 2000 * Math.pow(2, root.fingerQuiet));
|
|
fingerRetry.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: fingerRetry
|
|
|
|
onTriggered: {
|
|
root.fingerFailed = false;
|
|
root.fingerText = "";
|
|
root.startFinger();
|
|
}
|
|
}
|
|
|
|
// pam should never take this long; if it does, say so instead of spinning
|
|
Timer {
|
|
id: authTimeout
|
|
|
|
interval: 20000
|
|
onTriggered: {
|
|
pam.abort();
|
|
root.error("The check timed out");
|
|
}
|
|
}
|
|
|
|
// focus falls back to the glance face when the keyboard goes quiet
|
|
Timer {
|
|
id: idleBack
|
|
|
|
interval: 25000
|
|
onTriggered: {
|
|
if (root.locked && !root.busy && root.phase !== "prompting")
|
|
root.focused = false;
|
|
|
|
}
|
|
}
|
|
|
|
// ── faillock ───────────────────────────────────────────────────────────
|
|
FileView {
|
|
path: "/etc/security/faillock.conf"
|
|
onLoaded: {
|
|
var deny = /^\s*deny\s*=\s*(\d+)/m.exec(this.text());
|
|
var unlock = /^\s*unlock_time\s*=\s*(\d+)/m.exec(this.text());
|
|
if (deny)
|
|
root.denyAfter = parseInt(deny[1]);
|
|
|
|
if (unlock)
|
|
root.unlockAfter = parseInt(unlock[1]);
|
|
|
|
}
|
|
}
|
|
|
|
// pam only says "account locked"; faillock knows for how much longer
|
|
Process {
|
|
id: faillockProc
|
|
|
|
running: false
|
|
command: ["faillock", "--user", root.userName]
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
var latest = 0;
|
|
var valid = 0;
|
|
var lines = this.text.split("\n");
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var m = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\s+\S+.*\s(V|I)\s*$/.exec(lines[i].trim());
|
|
if (!m || m[2] !== "V")
|
|
continue;
|
|
|
|
valid += 1;
|
|
var t = Date.parse(m[1].replace(" ", "T"));
|
|
if (!isNaN(t) && t > latest)
|
|
latest = t;
|
|
|
|
}
|
|
root.attempts = Math.max(root.attempts, valid);
|
|
if (valid >= root.denyAfter && latest > 0) {
|
|
var left = Math.round((latest + root.unlockAfter * 1000 - Date.now()) / 1000);
|
|
root.lockoutLeft = Math.max(0, left);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
running: root.lockoutLeft > 0
|
|
interval: 1000
|
|
repeat: true
|
|
onTriggered: {
|
|
root.lockoutLeft -= 1;
|
|
if (root.lockoutLeft <= 0)
|
|
root.phase = "idle";
|
|
|
|
}
|
|
}
|
|
|
|
// ── keyboard locks ─────────────────────────────────────────────────────
|
|
// hyprland has no event for the lock keys, so the field polls while it is
|
|
// the thing being used
|
|
Timer {
|
|
running: root.locked && root.focused
|
|
interval: 400
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: kbProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: kbProc
|
|
|
|
running: false
|
|
command: ["hyprctl", "devices", "-j"]
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
var kbs = JSON.parse(this.text).keyboards || [];
|
|
var main = null;
|
|
for (var i = 0; i < kbs.length; i++) {
|
|
if (kbs[i].main) {
|
|
main = kbs[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!main && kbs.length > 0)
|
|
main = kbs[0];
|
|
|
|
if (!main)
|
|
return ;
|
|
|
|
root.capsLock = !!main.capsLock;
|
|
root.layout = main.active_keymap || "";
|
|
} catch (e) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── glance ─────────────────────────────────────────────────────────────
|
|
// read straight off the platform services rather than the shell's settings
|
|
// singletons: the lock must not drag nmcli/rfkill probes into shell startup
|
|
readonly property var battery: UPower.displayDevice
|
|
readonly property bool hasBattery: root.battery ? root.battery.isPresent : false
|
|
readonly property int batteryPercent: root.hasBattery ? Math.round(root.battery.percentage * 100) : 0
|
|
readonly property bool charging: root.hasBattery && (root.battery.state === UPowerDeviceState.Charging || root.battery.state === UPowerDeviceState.FullyCharged)
|
|
readonly property bool batteryLow: root.hasBattery && !root.charging && root.batteryPercent <= 15
|
|
|
|
readonly property var wifiDevice: {
|
|
for (const d of Networking.devices.values) {
|
|
if (d.type === DeviceType.Wifi)
|
|
return d;
|
|
|
|
}
|
|
return null;
|
|
}
|
|
readonly property var wiredDevice: {
|
|
for (const d of Networking.devices.values) {
|
|
if (d.type === DeviceType.Wired)
|
|
return d;
|
|
|
|
}
|
|
return null;
|
|
}
|
|
readonly property bool ethernet: !!(root.wiredDevice && root.wiredDevice.connected)
|
|
readonly property bool wifiUp: root.wifiDevice ? root.wifiDevice.connected : false
|
|
property var activeNetwork: null
|
|
// signalStrength is 0-1 on this backend, not a percentage
|
|
readonly property int wifiPercent: {
|
|
if (!root.activeNetwork || root.activeNetwork.signalStrength === undefined)
|
|
return 0;
|
|
|
|
const raw = root.activeNetwork.signalStrength;
|
|
return Math.round(raw <= 1 ? raw * 100 : raw);
|
|
}
|
|
readonly property string netGlyph: root.ethernet ? "ethernet" : (root.wifiUp ? "wifi" : "wifiOff")
|
|
readonly property string netLabel: {
|
|
if (root.ethernet)
|
|
return "Ethernet";
|
|
|
|
if (root.wifiUp && root.activeNetwork)
|
|
return root.activeNetwork.name;
|
|
|
|
if (root.wifiUp)
|
|
return "Wi-Fi";
|
|
|
|
return "Offline";
|
|
}
|
|
|
|
function refreshNetwork() {
|
|
if (!root.wifiDevice) {
|
|
root.activeNetwork = null;
|
|
return ;
|
|
}
|
|
var found = null;
|
|
for (const net of root.wifiDevice.networks.values) {
|
|
if (net.connected) {
|
|
found = net;
|
|
break;
|
|
}
|
|
}
|
|
root.activeNetwork = found;
|
|
}
|
|
|
|
Timer {
|
|
running: root.locked
|
|
interval: 5000
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: root.refreshNetwork()
|
|
}
|
|
|
|
readonly property var btAdapter: Bluetooth.defaultAdapter
|
|
readonly property bool btOn: root.btAdapter ? root.btAdapter.enabled : false
|
|
readonly property var btDevices: (root.btAdapter && root.btAdapter.devices) ? root.btAdapter.devices.values.filter((d) => {
|
|
return d.connected;
|
|
}) : []
|
|
readonly property string btLabel: {
|
|
if (!root.btAdapter)
|
|
return "No adapter";
|
|
|
|
if (!root.btOn)
|
|
return "Bluetooth off";
|
|
|
|
if (root.btDevices.length === 1)
|
|
return root.btDevices[0].name;
|
|
|
|
if (root.btDevices.length > 1)
|
|
return root.btDevices.length + " devices";
|
|
|
|
return "Bluetooth on";
|
|
}
|
|
|
|
// ── the day ────────────────────────────────────────────────────────────
|
|
// one tick a second so the clock and the greeting stay honest
|
|
property int tick: 0
|
|
|
|
Timer {
|
|
running: root.locked
|
|
interval: 1000
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: root.tick += 1
|
|
}
|
|
|
|
readonly property var now: {
|
|
void root.tick;
|
|
return Loc.now();
|
|
}
|
|
readonly property string timeText: {
|
|
var d = root.now;
|
|
var h = d.getHours();
|
|
var hh = Prefs.clock24h ? h : (h % 12 === 0 ? 12 : h % 12);
|
|
return (Prefs.clock24h && hh < 10 ? "0" : "") + hh + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes();
|
|
}
|
|
readonly property string hourText: root.timeText.split(":")[0]
|
|
readonly property string minuteText: root.timeText.split(":")[1]
|
|
readonly property string meridiem: Prefs.clock24h ? "" : (root.now.getHours() < 12 ? "AM" : "PM")
|
|
readonly property string dateText: root.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
|
|
readonly property string greeting: {
|
|
var h = root.now.getHours();
|
|
if (h < 5)
|
|
return "Still up?";
|
|
|
|
if (h < 12)
|
|
return "Good morning";
|
|
|
|
if (h < 18)
|
|
return "Good afternoon";
|
|
|
|
if (h < 22)
|
|
return "Good evening";
|
|
|
|
return "Good night";
|
|
}
|
|
|
|
// ── wallpaper ──────────────────────────────────────────────────────────
|
|
readonly property string wallpaper: {
|
|
var p = wallpaperFile.text().trim();
|
|
return p.length > 0 ? p : (Quickshell.env("HOME") + "/.config/quickshell/assets/fallback.jpg");
|
|
}
|
|
|
|
FileView {
|
|
id: wallpaperFile
|
|
|
|
path: Quickshell.env("HOME") + "/.cache/current_wallpaper"
|
|
watchChanges: true
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
// ── power ──────────────────────────────────────────────────────────────
|
|
// the same five the dock offers, with the same commands behind them
|
|
readonly property var powerActions: [
|
|
{
|
|
"id": "suspend",
|
|
"label": "Suspend",
|
|
"glyph": "suspend",
|
|
"confirm": false
|
|
},
|
|
{
|
|
"id": "hibernate",
|
|
"label": "Hibernate",
|
|
"glyph": "hibernate",
|
|
"confirm": false
|
|
},
|
|
{
|
|
"id": "logout",
|
|
"label": "Log out",
|
|
"glyph": "logout",
|
|
"confirm": true
|
|
},
|
|
{
|
|
"id": "reboot",
|
|
"label": "Restart",
|
|
"glyph": "restart",
|
|
"confirm": true
|
|
},
|
|
{
|
|
"id": "shutdown",
|
|
"label": "Shut down",
|
|
"glyph": "power",
|
|
"confirm": true
|
|
}
|
|
]
|
|
|
|
function runPower(id) {
|
|
var cmds = {
|
|
// uwsm only stops a session it started, so fall back to hyprland; a lua
|
|
// config reads a dispatch as lua, where a bare "exit" is no dispatcher
|
|
"logout": ["sh", "-c", "uwsm stop 2>/dev/null || hyprctl dispatch 'hl.dsp.exit()' || hyprctl dispatch exit"],
|
|
"suspend": ["systemctl", "suspend"],
|
|
"shutdown": ["systemctl", "poweroff"],
|
|
"hibernate": ["systemctl", "hibernate"],
|
|
"reboot": ["systemctl", "reboot"]
|
|
};
|
|
var cmd = cmds[id];
|
|
if (cmd)
|
|
Quickshell.execDetached(cmd);
|
|
|
|
}
|
|
|
|
// ── logind ─────────────────────────────────────────────────────────────
|
|
// `loginctl lock-session` is how anything off-shell asks for a lock, and
|
|
// logind answers it by emitting Lock on our own session object. hypridle
|
|
// relays that too, but only while it is running, and Idle can be turned
|
|
// off entirely — then nothing would be listening
|
|
//
|
|
// quickshell has no dbus binding, so gdbus does the listening (glib2 rides
|
|
// in under qt6-base, so it is always here). the path has to come from
|
|
// logind: the `auto` alias only resolves for calls, while signals arrive on
|
|
// the real, escaped one. exit 3 is ours, and means no session to watch
|
|
Process {
|
|
id: logindWatch
|
|
|
|
running: true
|
|
command: ["sh", "-c", "id=$(busctl --system get-property org.freedesktop.login1 /org/freedesktop/login1/session/auto org.freedesktop.login1.Session Id 2>/dev/null | sed 's/^s \"//;s/\"$//'); " + "[ -n \"$id\" ] || exit 3; " + "p=$(busctl --system call org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager GetSession s \"$id\" 2>/dev/null | sed 's/^o \"//;s/\"$//'); " + "[ -n \"$p\" ] || exit 3; " + "exec gdbus monitor --system --dest org.freedesktop.login1 --object-path \"$p\""]
|
|
onExited: (code) => {
|
|
if (code !== 3)
|
|
logindRetry.restart();
|
|
|
|
}
|
|
|
|
stdout: SplitParser {
|
|
// honouring Unlock is no weaker than `qs ipc call lock unlock`,
|
|
// which any process of this user can already call
|
|
onRead: (line) => {
|
|
var m = /\.Session\.(Lock|Unlock)\b/.exec(line);
|
|
if (!m)
|
|
return ;
|
|
|
|
if (m[1] === "Lock")
|
|
root.lock();
|
|
else
|
|
root.requestUnlock();
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// dbus going away takes the watcher with it; go back for it
|
|
Timer {
|
|
id: logindRetry
|
|
|
|
interval: 10000
|
|
onTriggered: logindWatch.running = true
|
|
}
|
|
|
|
// LockedHint is what loginctl and anything else reading the session go by,
|
|
// so it has to follow the surface. pushed at startup as well: a shell that
|
|
// died while locked would otherwise leave the hint stuck on
|
|
function pushLockedHint() {
|
|
Quickshell.execDetached(["busctl", "--system", "call", "org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetLockedHint", "b", root.locked ? "true" : "false"]);
|
|
}
|
|
|
|
onLockedChanged: root.pushLockedHint()
|
|
Component.onCompleted: {
|
|
root.pushLockedHint();
|
|
root.restoreXray();
|
|
root.forgetCaptures();
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "lock"
|
|
|
|
function lock(): void {
|
|
root.lock();
|
|
}
|
|
|
|
function unlock(): void {
|
|
root.requestUnlock();
|
|
}
|
|
|
|
function isLocked(): bool {
|
|
return root.locked;
|
|
}
|
|
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
"locked": root.locked,
|
|
"phase": root.phase,
|
|
"focused": root.focused,
|
|
"attempts": root.attempts,
|
|
"lockoutLeft": root.lockoutLeft
|
|
});
|
|
}
|
|
}
|
|
}
|