Add native themed session locker and Settings integration
This commit is contained in:
1 parent
8eca921345
commit
f8b8704f8b
18 files changed
+1422
-19
No files matched your search
Generated
+22
@@ -1627,6 +1627,22 @@ dependencies = [
|
|||||||
name = "ferese-layout"
|
name = "ferese-layout"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ferese-lock"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ferese-config",
|
||||||
|
"image",
|
||||||
|
"jiff",
|
||||||
|
"libc",
|
||||||
|
"libcosmic",
|
||||||
|
"libloading",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"wayland-client",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ferese-protocols"
|
name = "ferese-protocols"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -7187,6 +7203,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/ferese-protocols",
|
"crates/ferese-protocols",
|
||||||
"shell/ferese-shell",
|
"shell/ferese-shell",
|
||||||
"shell/ferese-settings",
|
"shell/ferese-settings",
|
||||||
|
"shell/ferese-lock",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -366,10 +366,16 @@ autostart {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ferese-lock` uses swaylock/swaylock-effects and optional
|
`ferese-lock` is the native PAM-authenticated locker. It follows your wallpaper,
|
||||||
`~/.config/ferese/swaylock.conf`. Test unlock in a nested preview before enabling
|
colors, font and shell corner radius. Its command returns successfully only after
|
||||||
automatic locking. A crashed locker leaves the session locked; recovery requires
|
the compositor confirms the lock; the UI keeps running until authentication
|
||||||
ending the session from another TTY.
|
succeeds. `--foreground` keeps the command attached to its UI process.
|
||||||
|
|
||||||
|
Use `ferese-lock --preview` for an ordinary window that never locks or checks a
|
||||||
|
password. See [Native locker](locking.md) for setup and verification. Test real
|
||||||
|
unlock in a nested compositor before enabling automatic locking. A crashed
|
||||||
|
locker leaves the session locked; recovery requires ending that session from
|
||||||
|
another TTY.
|
||||||
|
|
||||||
## Optional session protocols
|
## Optional session protocols
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Native locker
|
||||||
|
|
||||||
|
Ferese Lock uses `ext-session-lock-v1`, not a fullscreen application overlay.
|
||||||
|
Each output receives its own surface, including outputs added while locked.
|
||||||
|
The compositor protects the desktop if the client exits or crashes.
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --locked -p ferese-lock
|
||||||
|
target/release/ferese-lock --preview
|
||||||
|
```
|
||||||
|
|
||||||
|
This is an ordinary, closable window. Submitting the field does not call PAM,
|
||||||
|
lock, or unlock anything. Do not type your actual password into a visual preview.
|
||||||
|
|
||||||
|
Settings → Lock screen provides clock visibility, date visibility, time format,
|
||||||
|
wallpaper dimming and softness, and a safe preview button. Your account picture is loaded from
|
||||||
|
AccountsService or `~/.face`, with a user silhouette as a fallback.
|
||||||
|
|
||||||
|
The locker reads Ferese's wallpaper, theme colors, typography, and shell radius
|
||||||
|
at startup. The wallpaper is softened once and shared across displays. Set softness
|
||||||
|
to zero to keep it sharp. Escape clears the password. Caps Lock is shown above authentication
|
||||||
|
status. Password verification runs on a worker, and repeated attempts are held
|
||||||
|
for two seconds after failure, in addition to delays enforced by PAM.
|
||||||
|
|
||||||
|
## Installation and locking
|
||||||
|
|
||||||
|
The normal `scripts/install.sh` includes the native binary and installs
|
||||||
|
`/etc/pam.d/ferese-lock` if absent. Existing administrator policies are preserved.
|
||||||
|
Fedora/Arch installations use `system-auth`; Debian-family installations use
|
||||||
|
`common-auth` and `common-account`; the fallback uses the system `login` stack.
|
||||||
|
There is no setuid binary, password command argument, or shadow-file reader.
|
||||||
|
Both authentication and account validation must succeed. Expired credentials
|
||||||
|
must be updated outside the locker. Interactive multi-factor enrollment and
|
||||||
|
password changes are not supported by this initial password UI.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ferese-lock # returns after confirmation, UI stays running
|
||||||
|
ferese-lock --foreground # stays attached until unlock
|
||||||
|
```
|
||||||
|
|
||||||
|
The `swayidle -w` example in [Configuration](configuration.md#login-items-and-locking)
|
||||||
|
can invoke the default command. Auto-locking is opt-in. This change does not
|
||||||
|
install or start an idle daemon, replace your login manager, or change the Control Center's
|
||||||
|
suspend action. Test real password authentication inside a nested Ferese session
|
||||||
|
before enabling this on your desktop.
|
||||||
|
|
||||||
|
## Security and recovery
|
||||||
|
|
||||||
|
Never interpret the preview as proof of a locked desktop. A missing PAM policy
|
||||||
|
or unsupported compositor produces an error before a lock request. A failure
|
||||||
|
or authentication-worker panic never authorizes unlock. A crash after the lock
|
||||||
|
request may leave the session permanently locked; end that session from another
|
||||||
|
TTY if necessary. Do not restart the compositor to test crash behavior on a live
|
||||||
|
session with unsaved work.
|
||||||
|
|
||||||
|
The pinned libcosmic backend emits `Locked` twice: once when requesting a lock
|
||||||
|
and once on the actual protocol callback. The startup handshake waits for the
|
||||||
|
second notification. Re-audit this behavior whenever upgrading libcosmic. The
|
||||||
|
parent times out with an error after 15 seconds and leaves the UI running; it
|
||||||
|
never reports success merely because the window appeared.
|
||||||
|
|
||||||
|
Validation references: [session-lock protocol](https://wayland.app/protocols/ext-session-lock-v1),
|
||||||
|
[PAM authentication](https://man7.org/linux/man-pages/man3/pam_authenticate.3.html),
|
||||||
|
and [PAM account checks](https://man7.org/linux/man-pages/man3/pam_acct_mgmt.3.html).
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
config=${XDG_CONFIG_HOME:-$HOME/.config}/ferese/swaylock.conf
|
|
||||||
if [[ -f $config ]]; then
|
|
||||||
exec swaylock --config "$config" --daemonize
|
|
||||||
fi
|
|
||||||
exec swaylock --color 0f1014 --daemonize
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#%PAM-1.0
|
||||||
|
@include common-auth
|
||||||
|
@include common-account
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#%PAM-1.0
|
||||||
|
# Authenticate the existing session owner; do not open a new PAM session.
|
||||||
|
auth include system-auth
|
||||||
|
account include system-auth
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#%PAM-1.0
|
||||||
|
auth include login
|
||||||
|
account include login
|
||||||
@@ -39,12 +39,24 @@ if [[ -e $session_entry ]]; then
|
|||||||
echo "Different session entry exists: $session_entry" >&2; exit 1;
|
echo "Different session entry exists: $session_entry" >&2; exit 1;
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
for name in ferese ferese-shell ferese-settings feresectl; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
|
||||||
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
|
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
for name in ferese.png ferese.svg; do
|
for name in ferese.png ferese.svg; do
|
||||||
[[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; }
|
[[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
# Preserve administrator-supplied policies. New installs use the distro's
|
||||||
|
# authentication and account stacks, never a permissive fallback.
|
||||||
|
pam_policy=generic
|
||||||
|
if [[ -f /etc/pam.d/system-auth ]]; then
|
||||||
|
pam_policy=fedora
|
||||||
|
elif [[ -f /etc/pam.d/common-auth && -f /etc/pam.d/common-account ]]; then
|
||||||
|
pam_policy=debian
|
||||||
|
elif [[ ! -f /etc/pam.d/login && ! -f /etc/pam.d/ferese-lock ]]; then
|
||||||
|
echo 'No supported PAM stack found; install /etc/pam.d/ferese-lock first.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
||||||
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
|
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
|
||||||
|
|
||||||
@@ -52,11 +64,10 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
|
|||||||
if [[ -e $session_entry ]]; then
|
if [[ -e $session_entry ]]; then
|
||||||
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
|
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
|
||||||
fi
|
fi
|
||||||
for name in ferese ferese-shell ferese-settings feresectl; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
|
||||||
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
||||||
done
|
done
|
||||||
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
||||||
install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock"
|
|
||||||
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
|
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
|
||||||
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
|
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
|
||||||
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
|
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
|
||||||
@@ -65,6 +76,10 @@ install -d -m 0755 -- "$release_dir/wallpapers"
|
|||||||
install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.png" "$release_dir/wallpapers/ferese.png"
|
install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.png" "$release_dir/wallpapers/ferese.png"
|
||||||
install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.svg" "$release_dir/wallpapers/ferese.svg"
|
install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.svg" "$release_dir/wallpapers/ferese.svg"
|
||||||
|
|
||||||
|
if [[ ! -e /etc/pam.d/ferese-lock ]]; then
|
||||||
|
install -D -m 0644 -- "$repo_dir/packaging/pam.d/ferese-lock.$pam_policy" /etc/pam.d/ferese-lock
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -L $install_root/current ]]; then
|
if [[ -L $install_root/current ]]; then
|
||||||
ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous"
|
ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous"
|
||||||
fi
|
fi
|
||||||
@@ -78,4 +93,4 @@ install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/loc
|
|||||||
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg
|
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg
|
||||||
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg
|
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg
|
||||||
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi
|
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi
|
||||||
echo "Installed Ferese $release_id. Select Ferese in SDDM."
|
echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY."
|
||||||
+3
-3
@@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
|||||||
|
|
||||||
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
|
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
|
||||||
--target-dir "$repo_dir/target" --release --locked
|
--target-dir "$repo_dir/target" --release --locked
|
||||||
-p ferese -p ferese-shell -p ferese-settings -p feresectl)
|
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock)
|
||||||
if $offline; then build+=(--offline); fi
|
if $offline; then build+=(--offline); fi
|
||||||
if ! $skip_build; then
|
if ! $skip_build; then
|
||||||
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
|
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
|
||||||
@@ -75,8 +75,8 @@ fi
|
|||||||
|
|
||||||
cd -- "$repo_dir"
|
cd -- "$repo_dir"
|
||||||
if ! $skip_build; then "${build[@]}"; fi
|
if ! $skip_build; then "${build[@]}"; fi
|
||||||
for name in ferese ferese-shell ferese-settings feresectl; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
|
||||||
[[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name"
|
[[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name"
|
||||||
done
|
done
|
||||||
"${installer[@]}"
|
"${installer[@]}"
|
||||||
echo 'Installation complete. Log out and select Ferese at the login screen.'
|
echo 'Installation complete. Log out and select Ferese at your login screen, or run ferese-session from a local TTY.'
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Opt-in lock protocol smoke test. Opens a temporary nested compositor.
|
||||||
|
Run: FERESE_TEST_LOCK=1 python3 scripts/tests/test_lock_isolated.py
|
||||||
|
Requires release binaries, bwrap and grim. Host PAM is never modified.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_LOCK") == "1", "visible nested lock test is opt-in")
|
||||||
|
class NativeLockTest(unittest.TestCase):
|
||||||
|
def test_confirmation_capture_and_crash(self):
|
||||||
|
import os, pathlib, signal, subprocess, tempfile, time
|
||||||
|
os.chdir(Path(__file__).resolve().parents[2])
|
||||||
|
root=pathlib.Path(tempfile.mkdtemp(prefix='ferese-lock-test-'))
|
||||||
|
runtime=root/'runtime';runtime.mkdir(mode=0o700)
|
||||||
|
config=root/'config'/'ferese';config.mkdir(parents=True)
|
||||||
|
(config/'config.kdl').write_text('')
|
||||||
|
pam=root/'pam';pam.mkdir()
|
||||||
|
# Deny-only policy confined to a mount namespace; never changes host PAM.
|
||||||
|
(pam/'ferese-lock').write_text('auth required pam_deny.so\naccount required pam_deny.so\n')
|
||||||
|
host_display=os.environ.get('WAYLAND_DISPLAY','wayland-1')
|
||||||
|
if not host_display.startswith('/'):host_display=str(pathlib.Path(os.environ['XDG_RUNTIME_DIR'])/host_display)
|
||||||
|
env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1')
|
||||||
|
log=(root/'compositor.log').open('w')
|
||||||
|
compositor=subprocess.Popen(['target/release/ferese','--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT)
|
||||||
|
locker=None
|
||||||
|
locker_log=(root/'locker.log').open('w')
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
if compositor.poll() is not None:raise RuntimeError('Nested compositor exited')
|
||||||
|
sockets=[p for p in runtime.glob('wayland-*') if not p.name.endswith('.lock')]
|
||||||
|
if sockets:break
|
||||||
|
time.sleep(.1)
|
||||||
|
childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1')
|
||||||
|
time.sleep(.6)
|
||||||
|
subprocess.run(['grim',str(root/'unlocked.png')],env=childenv,check=True,timeout=10,capture_output=True)
|
||||||
|
locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--','target/release/ferese-lock'],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True)
|
||||||
|
assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text()
|
||||||
|
print('Native locker received compositor confirmation',flush=True)
|
||||||
|
result=subprocess.run(['grim',str(root/'locked.png')],env=childenv,timeout=10,capture_output=True)
|
||||||
|
assert result.returncode != 0, 'Capture unexpectedly succeeded during lock'
|
||||||
|
print('Screencopy denied while native locker is running',flush=True)
|
||||||
|
os.killpg(locker.pid, signal.SIGKILL)
|
||||||
|
time.sleep(.3)
|
||||||
|
result=subprocess.run(['grim',str(root/'after-crash.png')],env=childenv,timeout=10,capture_output=True)
|
||||||
|
assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash'
|
||||||
|
print('Screencopy remains denied after locker crash',flush=True)
|
||||||
|
finally:
|
||||||
|
if locker is not None:
|
||||||
|
try: os.killpg(locker.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError: pass
|
||||||
|
locker.wait()
|
||||||
|
compositor.terminate()
|
||||||
|
try:compositor.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:compositor.kill();compositor.wait()
|
||||||
|
log.close()
|
||||||
|
locker_log.close()
|
||||||
|
print(root,flush=True)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "ferese-lock"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ferese-config = { path = "../../crates/ferese-config" }
|
||||||
|
libcosmic.workspace = true
|
||||||
|
jiff.workspace = true
|
||||||
|
image = { version = "0.25.10", default-features = false }
|
||||||
|
libc.workspace = true
|
||||||
|
libloading = "0.8.9"
|
||||||
|
zeroize = "1.9"
|
||||||
|
wayland-client.workspace = true
|
||||||
|
tokio = { version = "1", features = ["rt", "time"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
use cosmic::{iced::Color, widget::image};
|
||||||
|
use ferese_config::Document;
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Appearance {
|
||||||
|
pub dim: f32,
|
||||||
|
pub show_clock: bool,
|
||||||
|
pub show_date: bool,
|
||||||
|
pub twelve_hour: bool,
|
||||||
|
pub panel: Color,
|
||||||
|
pub text: Color,
|
||||||
|
pub accent: Color,
|
||||||
|
pub radius: f32,
|
||||||
|
pub font: cosmic::font::Font,
|
||||||
|
pub wallpaper: image::Handle,
|
||||||
|
pub avatar: Option<image::Handle>,
|
||||||
|
}
|
||||||
|
impl Appearance {
|
||||||
|
pub fn load(user: &str, path: Option<&std::path::Path>) -> Self {
|
||||||
|
let doc = path
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.and_then(|s| Document::parse(&s).ok());
|
||||||
|
let string = |key: &str, fallback: &str| {
|
||||||
|
doc.as_ref()
|
||||||
|
.and_then(|d| d.get(key))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
.to_owned()
|
||||||
|
};
|
||||||
|
let color = |key, fallback| {
|
||||||
|
parse_color(&string(key, fallback)).unwrap_or_else(|| parse_color(fallback).unwrap())
|
||||||
|
};
|
||||||
|
let font = string("theme.typography.font_family", "Inter");
|
||||||
|
let path = string("theme.background.path", ferese_config::default_wallpaper());
|
||||||
|
let path = if let Some(tail) = path.strip_prefix("~/") {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(|home| format!("{home}/{tail}"))
|
||||||
|
.unwrap_or(path)
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
};
|
||||||
|
let wallpaper = image::Handle::from_path(path);
|
||||||
|
// Soften a bounded thumbnail once, before requesting the lock. The
|
||||||
|
// renderer scales this shared image across outputs; no per-frame blur.
|
||||||
|
let pixels = cosmic::iced::advanced::graphics::image::load(&wallpaper)
|
||||||
|
.or_else(|_| {
|
||||||
|
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_bytes(
|
||||||
|
include_bytes!("../../../assets/wallpapers/ferese.png").as_slice(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.expect("bundled wallpaper is valid");
|
||||||
|
let blur = doc
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("lock_screen.background_blur"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(18.)
|
||||||
|
.clamp(0., 40.) as f32;
|
||||||
|
let wallpaper = if blur > 0. {
|
||||||
|
let thumbnail = ::image::imageops::thumbnail(&pixels, 640, 640);
|
||||||
|
let softened = ::image::imageops::blur(&thumbnail, blur);
|
||||||
|
image::Handle::from_rgba(softened.width(), softened.height(), softened.into_raw())
|
||||||
|
} else {
|
||||||
|
image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())
|
||||||
|
};
|
||||||
|
let boolean = |key, fallback| {
|
||||||
|
doc.as_ref()
|
||||||
|
.and_then(|d| d.get(key))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
dim: doc
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("lock_screen.background_dim"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(0.48)
|
||||||
|
.clamp(0., 0.9) as f32,
|
||||||
|
show_clock: boolean("lock_screen.show_clock", true),
|
||||||
|
show_date: boolean("lock_screen.show_date", true),
|
||||||
|
twelve_hour: string("lock_screen.clock_format", "24h") == "12h",
|
||||||
|
panel: color("theme.colors.surface_base", "#111821"),
|
||||||
|
text: color("theme.colors.text_primary", "#F4F7FB"),
|
||||||
|
accent: color("theme.colors.accent", "#3D7BE6"),
|
||||||
|
radius: doc
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| {
|
||||||
|
d.get("theme.geometry.shell_radius")
|
||||||
|
.or_else(|| d.get("appearance.corner_radius"))
|
||||||
|
})
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(14.)
|
||||||
|
.clamp(0., 64.) as f32,
|
||||||
|
font: cosmic::font::Font::with_name(Box::leak(font.into_boxed_str())),
|
||||||
|
wallpaper,
|
||||||
|
avatar: account_picture(user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn clock_format(&self) -> &'static str {
|
||||||
|
if self.twelve_hour {
|
||||||
|
"%I:%M %p"
|
||||||
|
} else {
|
||||||
|
"%H:%M"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn theme(&self) -> cosmic::Theme {
|
||||||
|
use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba};
|
||||||
|
let color = |c: Color| Srgba::new(c.r, c.g, c.b, c.a);
|
||||||
|
let builder = if self.panel.r + self.panel.g + self.panel.b > 1.5 {
|
||||||
|
ThemeBuilder::light()
|
||||||
|
} else {
|
||||||
|
ThemeBuilder::dark()
|
||||||
|
};
|
||||||
|
let mut corners = cosmic::cosmic_theme::CornerRadii::default();
|
||||||
|
corners.radius_xs = [self.radius.min(4.); 4];
|
||||||
|
corners.radius_s = [self.radius.min(8.); 4];
|
||||||
|
corners.radius_m = [self.radius; 4];
|
||||||
|
corners.radius_l = [self.radius; 4];
|
||||||
|
corners.radius_xl = [self.radius; 4];
|
||||||
|
cosmic::Theme::custom(std::sync::Arc::new(
|
||||||
|
builder
|
||||||
|
.corner_radii(corners)
|
||||||
|
.bg_color(color(self.panel))
|
||||||
|
.primary_container_bg(color(self.panel))
|
||||||
|
.text_tint(color(self.text).color)
|
||||||
|
.accent(color(self.accent).color)
|
||||||
|
.build(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_color(value: &str) -> Option<Color> {
|
||||||
|
let hex = value.strip_prefix('#')?;
|
||||||
|
let packed = u32::from_str_radix(hex, 16).ok()?;
|
||||||
|
let rgba = match hex.len() {
|
||||||
|
6 => (packed << 8) | 255,
|
||||||
|
8 => packed,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Color::from_rgba8(
|
||||||
|
(rgba >> 24) as u8,
|
||||||
|
(rgba >> 16) as u8,
|
||||||
|
(rgba >> 8) as u8,
|
||||||
|
(rgba & 255) as f32 / 255.,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_picture(user: &str) -> Option<image::Handle> {
|
||||||
|
let mut candidates =
|
||||||
|
vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)];
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
candidates.push(std::path::PathBuf::from(home).join(".face"));
|
||||||
|
}
|
||||||
|
candidates.into_iter().find_map(|path| {
|
||||||
|
let pixels =
|
||||||
|
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path)).ok()?;
|
||||||
|
let side = pixels.width().min(pixels.height());
|
||||||
|
if side == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let square = ::image::imageops::crop_imm(
|
||||||
|
&pixels,
|
||||||
|
(pixels.width() - side) / 2,
|
||||||
|
(pixels.height() - side) / 2,
|
||||||
|
side,
|
||||||
|
side,
|
||||||
|
)
|
||||||
|
.to_image();
|
||||||
|
let mut pixels =
|
||||||
|
::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3);
|
||||||
|
// Clip the decoded thumbnail, so the avatar stays circular even on
|
||||||
|
// renderers that do not support rounded image clipping.
|
||||||
|
for (x, y, pixel) in pixels.enumerate_pixels_mut() {
|
||||||
|
let distance =
|
||||||
|
((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt();
|
||||||
|
let coverage = (128. - distance).clamp(0., 1.);
|
||||||
|
pixel[3] = (pixel[3] as f32 * coverage).round() as u8;
|
||||||
|
}
|
||||||
|
Some(image::Handle::from_rgba(
|
||||||
|
pixels.width(),
|
||||||
|
pixels.height(),
|
||||||
|
pixels.into_raw(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn lock_options_share_shell_style_without_changing_security() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let config = directory.path().join("config.kdl");
|
||||||
|
std::fs::write(
|
||||||
|
&config,
|
||||||
|
r##"
|
||||||
|
theme {
|
||||||
|
geometry { shell-radius 0; }
|
||||||
|
colors { accent "#FF5500"; }
|
||||||
|
}
|
||||||
|
lock-screen {
|
||||||
|
show-clock #false
|
||||||
|
show-date #false
|
||||||
|
clock-format "12h"
|
||||||
|
background-dim 0.7
|
||||||
|
}
|
||||||
|
"##,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let appearance = Appearance::load("no-such-test-user", Some(&config));
|
||||||
|
assert_eq!(appearance.radius, 0.);
|
||||||
|
assert_eq!(appearance.accent, Color::from_rgb8(255, 85, 0));
|
||||||
|
assert!(!appearance.show_clock && !appearance.show_date);
|
||||||
|
assert_eq!(appearance.clock_format(), "%I:%M %p");
|
||||||
|
assert_eq!(appearance.dim, 0.7);
|
||||||
|
assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//! PAM stays on a worker thread. No password is placed in argv, logs or IPC.
|
||||||
|
use std::ffi::{CStr, CString, c_char, c_int, c_void};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct Message {
|
||||||
|
style: c_int,
|
||||||
|
text: *const c_char,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
struct Response {
|
||||||
|
text: *mut c_char,
|
||||||
|
code: c_int,
|
||||||
|
}
|
||||||
|
#[repr(C)]
|
||||||
|
struct Conversation {
|
||||||
|
callback:
|
||||||
|
unsafe extern "C" fn(c_int, *mut *const Message, *mut *mut Response, *mut c_void) -> c_int,
|
||||||
|
data: *mut c_void,
|
||||||
|
}
|
||||||
|
struct Credentials {
|
||||||
|
user: CString,
|
||||||
|
password: Zeroizing<Vec<u8>>,
|
||||||
|
password_prompts: u8,
|
||||||
|
}
|
||||||
|
const SUCCESS: c_int = 0;
|
||||||
|
const CONV_ERR: c_int = 19;
|
||||||
|
|
||||||
|
// Linux-PAM owns successful response allocations and frees them using free().
|
||||||
|
unsafe extern "C" fn converse(
|
||||||
|
count: c_int,
|
||||||
|
messages: *mut *const Message,
|
||||||
|
out: *mut *mut Response,
|
||||||
|
data: *mut c_void,
|
||||||
|
) -> c_int {
|
||||||
|
if count <= 0 || count > 32 || messages.is_null() || out.is_null() || data.is_null() {
|
||||||
|
return CONV_ERR;
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
*out = std::ptr::null_mut();
|
||||||
|
let credentials = &mut *(data as *mut Credentials);
|
||||||
|
let responses =
|
||||||
|
libc::calloc(count as usize, std::mem::size_of::<Response>()) as *mut Response;
|
||||||
|
if responses.is_null() {
|
||||||
|
return CONV_ERR;
|
||||||
|
}
|
||||||
|
for i in 0..count as usize {
|
||||||
|
let msg = *messages.add(i);
|
||||||
|
let bytes = if msg.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match (*msg).style {
|
||||||
|
1 => {
|
||||||
|
credentials.password_prompts =
|
||||||
|
credentials.password_prompts.saturating_add(1);
|
||||||
|
(credentials.password_prompts == 1)
|
||||||
|
.then_some(credentials.password.as_slice())
|
||||||
|
} // Never send the password as a second-factor response.
|
||||||
|
2 => Some(credentials.user.as_bytes_with_nul()), // PAM_PROMPT_ECHO_ON
|
||||||
|
3 | 4 => Some(&[][..]), // PAM_ERROR_MSG / PAM_TEXT_INFO
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(bytes) = bytes {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
let allocation = libc::malloc(bytes.len()) as *mut c_char;
|
||||||
|
if !allocation.is_null() {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
bytes.as_ptr(),
|
||||||
|
allocation.cast(),
|
||||||
|
bytes.len(),
|
||||||
|
);
|
||||||
|
(*responses.add(i)).text = allocation;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j in 0..i {
|
||||||
|
let ptr = (*responses.add(j)).text;
|
||||||
|
if !ptr.is_null() {
|
||||||
|
libc::explicit_bzero(ptr.cast(), CStr::from_ptr(ptr).to_bytes().len());
|
||||||
|
libc::free(ptr.cast());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
libc::free(responses.cast());
|
||||||
|
return CONV_ERR;
|
||||||
|
}
|
||||||
|
*out = responses;
|
||||||
|
SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn username() -> Result<String, String> {
|
||||||
|
// Never trust USER/LOGNAME for the account being unlocked.
|
||||||
|
unsafe {
|
||||||
|
let mut entry: libc::passwd = std::mem::zeroed();
|
||||||
|
let mut result = std::ptr::null_mut();
|
||||||
|
let mut buffer = vec![0u8; 16384];
|
||||||
|
let status = libc::getpwuid_r(
|
||||||
|
libc::getuid(),
|
||||||
|
&mut entry,
|
||||||
|
buffer.as_mut_ptr().cast(),
|
||||||
|
buffer.len(),
|
||||||
|
&mut result,
|
||||||
|
);
|
||||||
|
if status != 0 || result.is_null() || entry.pw_name.is_null() {
|
||||||
|
return Err("Cannot determine the session user".into());
|
||||||
|
}
|
||||||
|
Ok(CStr::from_ptr(entry.pw_name).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available() -> bool {
|
||||||
|
std::path::Path::new("/etc/pam.d/ferese-lock").is_file()
|
||||||
|
&& unsafe { libloading::Library::new("libpam.so.0") }.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(user: String, password: Zeroizing<String>) -> bool {
|
||||||
|
authenticate_with(&user, &password, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authenticate_with(user: &str, password: &str, config: Option<&CStr>) -> bool {
|
||||||
|
if password.is_empty() || password.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(user) = CString::new(user) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut secret = Zeroizing::new(password.as_bytes().to_vec());
|
||||||
|
secret.push(0);
|
||||||
|
let mut credentials = Credentials {
|
||||||
|
user,
|
||||||
|
password: secret,
|
||||||
|
password_prompts: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let Ok(library) = libloading::Library::new("libpam.so.0") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
type Start = unsafe extern "C" fn(
|
||||||
|
*const c_char,
|
||||||
|
*const c_char,
|
||||||
|
*const Conversation,
|
||||||
|
*mut *mut c_void,
|
||||||
|
) -> c_int;
|
||||||
|
type StartConf = unsafe extern "C" fn(
|
||||||
|
*const c_char,
|
||||||
|
*const c_char,
|
||||||
|
*const Conversation,
|
||||||
|
*const c_char,
|
||||||
|
*mut *mut c_void,
|
||||||
|
) -> c_int;
|
||||||
|
type Check = unsafe extern "C" fn(*mut c_void, c_int) -> c_int;
|
||||||
|
let (Ok(start), Ok(auth), Ok(account), Ok(end)) = (
|
||||||
|
library.get::<Start>(b"pam_start\0"),
|
||||||
|
library.get::<Check>(b"pam_authenticate\0"),
|
||||||
|
library.get::<Check>(b"pam_acct_mgmt\0"),
|
||||||
|
library.get::<Check>(b"pam_end\0"),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let conversation = Conversation {
|
||||||
|
callback: converse,
|
||||||
|
data: (&mut credentials as *mut Credentials).cast(),
|
||||||
|
};
|
||||||
|
let mut handle = std::ptr::null_mut();
|
||||||
|
let status = if let Some(config) = config {
|
||||||
|
let Ok(start) = library.get::<StartConf>(b"pam_start_confdir\0") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
start(
|
||||||
|
c"ferese-lock".as_ptr(),
|
||||||
|
credentials.user.as_ptr(),
|
||||||
|
&conversation,
|
||||||
|
config.as_ptr(),
|
||||||
|
&mut handle,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
start(
|
||||||
|
c"ferese-lock".as_ptr(),
|
||||||
|
credentials.user.as_ptr(),
|
||||||
|
&conversation,
|
||||||
|
&mut handle,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if status != SUCCESS || handle.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut status = auth(handle, 1); // PAM_DISALLOW_NULL_AUTHTOK
|
||||||
|
if status == SUCCESS {
|
||||||
|
status = account(handle, 0);
|
||||||
|
}
|
||||||
|
let ended = end(handle, status);
|
||||||
|
status == SUCCESS && ended == SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn pam_requires_authentication_and_account_permission() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = CString::new(directory.path().as_os_str().as_encoded_bytes()).unwrap();
|
||||||
|
for (auth, account, allowed) in [
|
||||||
|
("permit", "permit", true),
|
||||||
|
("deny", "permit", false),
|
||||||
|
("permit", "deny", false),
|
||||||
|
] {
|
||||||
|
std::fs::write(
|
||||||
|
directory.path().join("ferese-lock"),
|
||||||
|
format!("auth required pam_{auth}.so\naccount required pam_{account}.so\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(authenticate_with("test", "test-only", Some(&path)), allowed);
|
||||||
|
}
|
||||||
|
assert!(!authenticate_with("test", "", Some(&path)));
|
||||||
|
assert!(!authenticate_with("test", "bad\0value", Some(&path)));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn conversation_rejects_multiple_secret_prompts_and_unknown_styles() {
|
||||||
|
let mut credentials = Credentials {
|
||||||
|
user: CString::new("tester").unwrap(),
|
||||||
|
password: Zeroizing::new(b"test-only\0".to_vec()),
|
||||||
|
password_prompts: 0,
|
||||||
|
};
|
||||||
|
let first = Message {
|
||||||
|
style: 1,
|
||||||
|
text: c"Password".as_ptr(),
|
||||||
|
};
|
||||||
|
let mut prompts = [&first as *const Message, &first as *const Message];
|
||||||
|
let mut responses = std::ptr::null_mut();
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(
|
||||||
|
converse(
|
||||||
|
2,
|
||||||
|
prompts.as_mut_ptr(),
|
||||||
|
&mut responses,
|
||||||
|
(&mut credentials as *mut Credentials).cast()
|
||||||
|
),
|
||||||
|
CONV_ERR
|
||||||
|
);
|
||||||
|
assert!(responses.is_null());
|
||||||
|
let unknown = Message {
|
||||||
|
style: 99,
|
||||||
|
text: std::ptr::null(),
|
||||||
|
};
|
||||||
|
prompts[0] = &unknown;
|
||||||
|
assert_eq!(
|
||||||
|
converse(
|
||||||
|
1,
|
||||||
|
prompts.as_mut_ptr(),
|
||||||
|
&mut responses,
|
||||||
|
(&mut credentials as *mut Credentials).cast()
|
||||||
|
),
|
||||||
|
CONV_ERR
|
||||||
|
);
|
||||||
|
assert!(responses.is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn current_user_comes_from_uid() {
|
||||||
|
assert!(!username().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
mod appearance;
|
||||||
|
mod auth;
|
||||||
|
mod runtime;
|
||||||
|
|
||||||
|
use cosmic::iced::platform_specific::shell::commands::session_lock;
|
||||||
|
use cosmic::{
|
||||||
|
Element,
|
||||||
|
app::{Core, Settings, Task},
|
||||||
|
iced::{
|
||||||
|
self, Event, Length, Subscription,
|
||||||
|
event::{self, PlatformSpecific, wayland},
|
||||||
|
window,
|
||||||
|
},
|
||||||
|
theme,
|
||||||
|
widget::{self, column, container, image, row},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use wayland_client::protocol::wl_output::WlOutput;
|
||||||
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||||
|
if args == ["--help"] {
|
||||||
|
println!(
|
||||||
|
"Usage: ferese-lock [--preview | --foreground]\nWithout arguments, lock the session using PAM.\n--preview opens an ordinary window; it never locks or authenticates."
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let preview = args == ["--preview"]
|
||||||
|
|| (args.len() == 3 && args[0] == "--preview" && args[1] == "--config");
|
||||||
|
let config = if preview && args.len() == 3 {
|
||||||
|
Some(std::path::PathBuf::from(&args[2]))
|
||||||
|
} else {
|
||||||
|
ferese_config::config_path()
|
||||||
|
};
|
||||||
|
if !args.is_empty() && !preview && args != ["--foreground"] {
|
||||||
|
eprintln!("Usage: ferese-lock [--preview | --foreground]");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
if !preview && !auth::available() {
|
||||||
|
eprintln!("ferese-lock: install the ferese-lock PAM policy before locking");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
if !preview {
|
||||||
|
if let Err(error) = runtime::check_protocol() {
|
||||||
|
eprintln!("ferese-lock: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
if args.is_empty() {
|
||||||
|
if let Err(error) = runtime::daemonize() {
|
||||||
|
eprintln!("ferese-lock: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let user = auth::username().unwrap_or_else(|error| {
|
||||||
|
eprintln!("ferese-lock: {error}");
|
||||||
|
std::process::exit(1)
|
||||||
|
});
|
||||||
|
// Password-bearing UI processes must not create core dumps.
|
||||||
|
unsafe {
|
||||||
|
libc::prctl(libc::PR_SET_DUMPABLE, 0);
|
||||||
|
}
|
||||||
|
let appearance = appearance::Appearance::load(&user, config.as_deref());
|
||||||
|
cosmic::app::run::<Locker>(
|
||||||
|
Settings::default()
|
||||||
|
.no_main_window(!preview)
|
||||||
|
.is_daemon(!preview)
|
||||||
|
.client_decorations(false)
|
||||||
|
.default_text_size(14.)
|
||||||
|
.default_font(appearance.font)
|
||||||
|
.theme(appearance.theme())
|
||||||
|
.antialiasing(true),
|
||||||
|
(preview, user, appearance),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Message {
|
||||||
|
Event(Event),
|
||||||
|
Input(String),
|
||||||
|
Submit,
|
||||||
|
Authenticated(bool),
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
// Never derive Debug for password-bearing messages.
|
||||||
|
impl std::fmt::Debug for Message {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("LockerMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Default, Debug, PartialEq)]
|
||||||
|
enum AuthState {
|
||||||
|
#[default]
|
||||||
|
Ready,
|
||||||
|
Checking,
|
||||||
|
Rejected(Instant),
|
||||||
|
Unlocking,
|
||||||
|
}
|
||||||
|
impl AuthState {
|
||||||
|
fn may_submit(&self) -> bool {
|
||||||
|
matches!(self, Self::Ready)
|
||||||
|
}
|
||||||
|
fn complete(&mut self, success: bool) -> bool {
|
||||||
|
if *self != Self::Checking {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*self = if success {
|
||||||
|
Self::Unlocking
|
||||||
|
} else {
|
||||||
|
Self::Rejected(Instant::now())
|
||||||
|
};
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Locker {
|
||||||
|
core: Core,
|
||||||
|
preview: bool,
|
||||||
|
user: String,
|
||||||
|
appearance: appearance::Appearance,
|
||||||
|
password: Zeroizing<String>,
|
||||||
|
state: AuthState,
|
||||||
|
status: &'static str,
|
||||||
|
outputs: HashMap<WlOutput, window::Id>,
|
||||||
|
started: bool,
|
||||||
|
clock: String,
|
||||||
|
date: String,
|
||||||
|
confirmation: runtime::Confirmation,
|
||||||
|
caps_lock: bool,
|
||||||
|
}
|
||||||
|
impl cosmic::Application for Locker {
|
||||||
|
type Executor = cosmic::executor::Default;
|
||||||
|
type Flags = (bool, String, appearance::Appearance);
|
||||||
|
type Message = Message;
|
||||||
|
const APP_ID: &'static str = "dev.ferese.Lock";
|
||||||
|
fn core(&self) -> &Core {
|
||||||
|
&self.core
|
||||||
|
}
|
||||||
|
fn core_mut(&mut self) -> &mut Core {
|
||||||
|
&mut self.core
|
||||||
|
}
|
||||||
|
fn init(mut core: Core, (preview, user, appearance): Self::Flags) -> (Self, Task<Message>) {
|
||||||
|
core.window.show_headerbar = false;
|
||||||
|
let now = jiff::Zoned::now();
|
||||||
|
let clock = now.strftime(appearance.clock_format()).to_string();
|
||||||
|
let title_task = core
|
||||||
|
.main_window_id()
|
||||||
|
.map(|id| cosmic::command::set_title(id, "Ferese Lock Preview".to_owned()))
|
||||||
|
.unwrap_or_else(Task::none);
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
core,
|
||||||
|
preview,
|
||||||
|
user,
|
||||||
|
appearance,
|
||||||
|
password: Zeroizing::new(String::new()),
|
||||||
|
state: AuthState::Ready,
|
||||||
|
status: "Press Enter to unlock",
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
started: false,
|
||||||
|
confirmation: Default::default(),
|
||||||
|
caps_lock: false,
|
||||||
|
clock,
|
||||||
|
date: now.strftime("%A, %B %-d").to_string(),
|
||||||
|
},
|
||||||
|
if preview {
|
||||||
|
Task::batch([title_task, focus()])
|
||||||
|
} else {
|
||||||
|
session_lock::lock()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
Subscription::batch([
|
||||||
|
event::listen_with(|event, _, _| match event {
|
||||||
|
Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||||
|
wayland::Event::Output(..) | wayland::Event::SessionLock(..),
|
||||||
|
))
|
||||||
|
| Event::Window(window::Event::Opened { .. })
|
||||||
|
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => {
|
||||||
|
Some(Message::Event(event))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::Event(Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers))) => {
|
||||||
|
self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK);
|
||||||
|
}
|
||||||
|
Message::Tick => {
|
||||||
|
let now = jiff::Zoned::now();
|
||||||
|
self.clock = now.strftime(self.appearance.clock_format()).to_string();
|
||||||
|
self.date = now.strftime("%A, %B %-d").to_string();
|
||||||
|
if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2))
|
||||||
|
{
|
||||||
|
self.state = AuthState::Ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Input(mut value) => {
|
||||||
|
if self.state.may_submit() && value.len() <= 4096 {
|
||||||
|
self.password.zeroize();
|
||||||
|
*self.password = std::mem::take(&mut value);
|
||||||
|
self.status = "Press Enter to unlock";
|
||||||
|
}
|
||||||
|
value.zeroize();
|
||||||
|
}
|
||||||
|
Message::Submit => {
|
||||||
|
if self.preview {
|
||||||
|
self.password.zeroize();
|
||||||
|
self.status = "Preview only · your session is not locked";
|
||||||
|
return focus();
|
||||||
|
}
|
||||||
|
if !self.confirmation.confirmed()
|
||||||
|
|| !self.state.may_submit()
|
||||||
|
|| self.password.is_empty()
|
||||||
|
{
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.state = AuthState::Checking;
|
||||||
|
self.status = "Checking password…";
|
||||||
|
let password = Zeroizing::new(std::mem::take(&mut *self.password));
|
||||||
|
let user = self.user.clone();
|
||||||
|
return Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || auth::authenticate(user, password))
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
},
|
||||||
|
|accepted| cosmic::Action::App(Message::Authenticated(accepted)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Message::Authenticated(accepted) => {
|
||||||
|
if self.state.complete(accepted) && !self.preview {
|
||||||
|
return session_lock::unlock();
|
||||||
|
}
|
||||||
|
self.status = "Password not accepted. Try again.";
|
||||||
|
return focus();
|
||||||
|
}
|
||||||
|
Message::Event(Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||||
|
wayland::Event::Output(event, output),
|
||||||
|
))) if !self.preview => {
|
||||||
|
if matches!(event, wayland::OutputEvent::Removed) {
|
||||||
|
if let Some(id) = self.outputs.remove(&output) {
|
||||||
|
return session_lock::destroy_lock_surface(id);
|
||||||
|
}
|
||||||
|
} else if !self.outputs.contains_key(&output) {
|
||||||
|
let id = window::Id::unique();
|
||||||
|
self.outputs.insert(output.clone(), id);
|
||||||
|
if self.started {
|
||||||
|
return session_lock::get_lock_surface(id, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Event(Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||||
|
wayland::Event::SessionLock(event),
|
||||||
|
))) if !self.preview => match event {
|
||||||
|
wayland::SessionLockEvent::Locked => {
|
||||||
|
if self.confirmation.observe() {
|
||||||
|
use std::io::Write;
|
||||||
|
if std::env::var_os("FERESE_LOCK_READY").is_some() {
|
||||||
|
let _ = writeln!(std::io::stdout().lock(), "FERESE_LOCKED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.started {
|
||||||
|
self.started = true;
|
||||||
|
return Task::batch(self.outputs.iter().map(|(output, id)| {
|
||||||
|
session_lock::get_lock_surface(*id, output.clone())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wayland::SessionLockEvent::Focused(..) => return focus(),
|
||||||
|
wayland::SessionLockEvent::Unlocked if self.state == AuthState::Unlocking => {
|
||||||
|
return iced::exit();
|
||||||
|
}
|
||||||
|
wayland::SessionLockEvent::Finished | wayland::SessionLockEvent::NotSupported => {
|
||||||
|
eprintln!("ferese-lock: compositor refused the lock");
|
||||||
|
return iced::exit();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Message::Event(Event::Window(window::Event::Opened { .. })) => return focus(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
fn on_escape(&mut self) -> Task<Message> {
|
||||||
|
self.password.zeroize();
|
||||||
|
focus()
|
||||||
|
}
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
self.screen()
|
||||||
|
}
|
||||||
|
fn view_window(&self, _: window::Id) -> Element<'_, Message> {
|
||||||
|
self.screen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn focus() -> Task<Message> {
|
||||||
|
widget::text_input::focus(widget::Id::new("password"))
|
||||||
|
}
|
||||||
|
impl Locker {
|
||||||
|
fn label<'a>(
|
||||||
|
&self,
|
||||||
|
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||||||
|
size: u16,
|
||||||
|
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||||||
|
widget::text(label).font(self.appearance.font).size(size)
|
||||||
|
}
|
||||||
|
fn screen(&self) -> Element<'_, Message> {
|
||||||
|
iced::widget::responsive(move |size| self.screen_content(size)).into()
|
||||||
|
}
|
||||||
|
fn screen_content(&self, size: iced::Size) -> Element<'_, Message> {
|
||||||
|
let avatar_size = (size.height * 0.09).clamp(64., 112.);
|
||||||
|
let clock_size = (size.height * 0.165)
|
||||||
|
.min(
|
||||||
|
size.width
|
||||||
|
* if self.appearance.twelve_hour {
|
||||||
|
0.075
|
||||||
|
} else {
|
||||||
|
0.12
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clamp(40., 180.) as u16;
|
||||||
|
let margin = if size.width < 640. { 24. } else { 36. };
|
||||||
|
let top = (size.height * 0.09).max(24.);
|
||||||
|
let a = &self.appearance;
|
||||||
|
let mut input = widget::text_input("Password", self.password.as_str())
|
||||||
|
.password()
|
||||||
|
.padding([8, 18])
|
||||||
|
.style(password_style(a.radius, a.accent, a.panel))
|
||||||
|
.font(a.font)
|
||||||
|
.id(widget::Id::new("password"));
|
||||||
|
if self.state.may_submit() {
|
||||||
|
input = input.on_input(Message::Input).on_submit(|mut value| {
|
||||||
|
value.zeroize();
|
||||||
|
Message::Submit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let submit = widget::button::custom(widget::icon::from_name("go-next-symbolic").size(20))
|
||||||
|
.class(theme::Button::Icon)
|
||||||
|
.padding(6)
|
||||||
|
.on_press_maybe(self.state.may_submit().then_some(Message::Submit));
|
||||||
|
input = input.trailing_icon(container(submit).padding([0, 6]).into());
|
||||||
|
let avatar: Element<'_, Message> = if let Some(photo) = &a.avatar {
|
||||||
|
image(photo.clone())
|
||||||
|
.width(avatar_size)
|
||||||
|
.height(avatar_size)
|
||||||
|
.content_fit(iced::ContentFit::Cover)
|
||||||
|
.border_radius(avatar_size / 2.)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
container(line_icon(
|
||||||
|
"M16 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M4 21v-2a8 8 0 0 1 16 0v2",
|
||||||
|
46,
|
||||||
|
))
|
||||||
|
.center_x(avatar_size)
|
||||||
|
.center_y(avatar_size)
|
||||||
|
.class(theme::Container::custom(move |_| {
|
||||||
|
widget::container::Style {
|
||||||
|
background: Some(iced::Background::Color(a.accent.scale_alpha(0.18))),
|
||||||
|
text_color: Some(a.accent),
|
||||||
|
border: iced::Border {
|
||||||
|
radius: (avatar_size / 2.).into(),
|
||||||
|
width: 1.,
|
||||||
|
color: a.accent.scale_alpha(0.3),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
let identity = column![
|
||||||
|
avatar,
|
||||||
|
self.label(&self.user, 16).class(iced::Color::WHITE),
|
||||||
|
widget::Space::new().height(6),
|
||||||
|
input,
|
||||||
|
self.label(
|
||||||
|
if self.caps_lock {
|
||||||
|
"Caps Lock is on"
|
||||||
|
} else {
|
||||||
|
self.status
|
||||||
|
},
|
||||||
|
12
|
||||||
|
)
|
||||||
|
.class(iced::Color::WHITE.scale_alpha(0.68)),
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.align_x(iced::Alignment::Center)
|
||||||
|
.width(
|
||||||
|
(size.height * 0.37)
|
||||||
|
.clamp(260., 420.)
|
||||||
|
.min((size.width - margin * 2.).max(160.)),
|
||||||
|
);
|
||||||
|
let mut clock = column([]).spacing(0).align_x(iced::Alignment::Center);
|
||||||
|
if a.show_clock {
|
||||||
|
clock = clock.push(
|
||||||
|
self.label(&self.clock, clock_size)
|
||||||
|
.font(cosmic::font::Font {
|
||||||
|
weight: cosmic::iced::font::Weight::Light,
|
||||||
|
..a.font
|
||||||
|
})
|
||||||
|
.class(iced::Color::WHITE),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if a.show_date {
|
||||||
|
clock = clock.push(
|
||||||
|
self.label(&self.date, 18)
|
||||||
|
.class(iced::Color::WHITE.scale_alpha(0.72)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let clock_height = if a.show_clock {
|
||||||
|
clock_size as f32 * 1.3
|
||||||
|
} else {
|
||||||
|
0.
|
||||||
|
} + if a.show_date { 24. } else { 0. };
|
||||||
|
let gap = (size.height * 0.44 - top - clock_height).max(24.);
|
||||||
|
let foreground = container(
|
||||||
|
column![
|
||||||
|
container(clock).center_x(Length::Fill),
|
||||||
|
widget::Space::new().height(gap),
|
||||||
|
container(identity).center_x(Length::Fill),
|
||||||
|
widget::Space::new().height(Length::Fill),
|
||||||
|
row![
|
||||||
|
row![ferese_symbol(), self.label("Ferese", 13)]
|
||||||
|
.spacing(5)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
widget::Space::new().width(Length::Fill),
|
||||||
|
self.label(
|
||||||
|
if self.preview {
|
||||||
|
"Preview · your session is not locked"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
11
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
]
|
||||||
|
.height(Length::Fill),
|
||||||
|
)
|
||||||
|
.padding(iced::Padding {
|
||||||
|
top,
|
||||||
|
right: margin,
|
||||||
|
bottom: margin,
|
||||||
|
left: margin,
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.class(theme::Container::custom(move |_| {
|
||||||
|
widget::container::Style {
|
||||||
|
text_color: Some(iced::Color::WHITE.scale_alpha(0.65)),
|
||||||
|
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||||
|
0.025, 0.035, 0.06, a.dim,
|
||||||
|
))),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let background: Element<'_, Message> = image(a.wallpaper.clone())
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.content_fit(iced::ContentFit::Cover)
|
||||||
|
.into();
|
||||||
|
iced::widget::stack![background, foreground].into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn only_an_active_successful_attempt_unlocks() {
|
||||||
|
let mut state = AuthState::Ready;
|
||||||
|
assert!(!state.complete(true));
|
||||||
|
state = AuthState::Checking;
|
||||||
|
assert!(!state.complete(false));
|
||||||
|
assert!(!state.may_submit());
|
||||||
|
assert!(!state.complete(true));
|
||||||
|
state = AuthState::Checking;
|
||||||
|
assert!(state.complete(true));
|
||||||
|
assert!(!state.complete(true));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn password_messages_are_redacted() {
|
||||||
|
assert!(!format!("{:?}", Message::Input("secret".into())).contains("secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_icon(path: &str, size: u16) -> widget::icon::Icon {
|
||||||
|
let svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"#
|
||||||
|
);
|
||||||
|
widget::icon(widget::icon::from_svg_bytes(svg.into_bytes()).symbolic(true)).size(size)
|
||||||
|
}
|
||||||
|
fn ferese_symbol() -> widget::icon::Icon {
|
||||||
|
widget::icon(
|
||||||
|
widget::icon::from_svg_bytes(
|
||||||
|
include_bytes!("../../../packaging/icons/ferese.svg").as_slice(),
|
||||||
|
)
|
||||||
|
.symbolic(true),
|
||||||
|
)
|
||||||
|
.size(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn password_style(radius: f32, accent: iced::Color, surface: iced::Color) -> theme::TextInput {
|
||||||
|
let appearance = move |focused: bool| widget::text_input::Appearance {
|
||||||
|
background: iced::Color::from_rgba(
|
||||||
|
(surface.r + 1.) * 0.5,
|
||||||
|
(surface.g + 1.) * 0.5,
|
||||||
|
(surface.b + 1.) * 0.5,
|
||||||
|
0.24,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
border_radius: (radius * 2.).min(26.).into(),
|
||||||
|
border_width: 1.,
|
||||||
|
border_offset: None,
|
||||||
|
border_color: if focused {
|
||||||
|
accent.scale_alpha(0.7)
|
||||||
|
} else {
|
||||||
|
iced::Color::WHITE.scale_alpha(0.15)
|
||||||
|
},
|
||||||
|
icon_color: Some(iced::Color::WHITE.scale_alpha(0.8)),
|
||||||
|
text_color: Some(iced::Color::WHITE),
|
||||||
|
placeholder_color: iced::Color::WHITE.scale_alpha(0.55),
|
||||||
|
selected_text_color: iced::Color::WHITE,
|
||||||
|
selected_fill: accent.scale_alpha(0.5),
|
||||||
|
label_color: iced::Color::WHITE,
|
||||||
|
};
|
||||||
|
theme::TextInput::Custom {
|
||||||
|
active: Box::new(move |_| appearance(false)),
|
||||||
|
hovered: Box::new(move |_| appearance(true)),
|
||||||
|
focused: Box::new(move |_| appearance(true)),
|
||||||
|
error: Box::new(move |_| appearance(false)),
|
||||||
|
disabled: Box::new(move |_| appearance(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Startup handshake: the invoking command exits successfully only after the
|
||||||
|
//! compositor's confirmation. The UI child remains alive for authentication.
|
||||||
|
use std::{
|
||||||
|
io::{BufRead, BufReader},
|
||||||
|
process::{Command, Stdio},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use wayland_client::{
|
||||||
|
Connection, Dispatch, QueueHandle,
|
||||||
|
globals::{GlobalListContents, registry_queue_init},
|
||||||
|
protocol::wl_registry,
|
||||||
|
};
|
||||||
|
struct Registry;
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &wl_registry::WlRegistry,
|
||||||
|
_: wl_registry::Event,
|
||||||
|
_: &GlobalListContents,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn check_protocol() -> Result<(), String> {
|
||||||
|
let connection =
|
||||||
|
Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?;
|
||||||
|
let (globals, _) = registry_queue_init::<Registry>(&connection)
|
||||||
|
.map_err(|e| format!("Wayland registry: {e}"))?;
|
||||||
|
if globals
|
||||||
|
.contents()
|
||||||
|
.clone_list()
|
||||||
|
.iter()
|
||||||
|
.any(|g| g.interface == "ext_session_lock_manager_v1")
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("the compositor does not support secure session locking".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn daemonize() -> Result<(), String> {
|
||||||
|
let executable = std::env::current_exe().map_err(|e| format!("Locker executable: {e}"))?;
|
||||||
|
let mut child = Command::new(&executable)
|
||||||
|
.arg("--foreground")
|
||||||
|
.env("FERESE_LOCK_READY", "1")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Starting locker {}: {e}", executable.display()))?;
|
||||||
|
let stdout = child.stdout.take().unwrap();
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut reader = BufReader::new(stdout);
|
||||||
|
let mut line = String::new();
|
||||||
|
let ready = reader.read_line(&mut line).is_ok() && line.trim_end() == "FERESE_LOCKED";
|
||||||
|
let _ = tx.send(ready);
|
||||||
|
});
|
||||||
|
match rx.recv_timeout(Duration::from_secs(15)) {
|
||||||
|
Ok(true) => Ok(()),
|
||||||
|
Ok(false) => {
|
||||||
|
let _ = child.wait();
|
||||||
|
Err("locker exited without confirming protection".into())
|
||||||
|
}
|
||||||
|
Err(_) => Err("lock confirmation timed out; the locker was left running".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// libcosmic revision 03d7dcb emits one synthetic Locked on the request and
|
||||||
|
/// another from SCTK's actual protocol callback. Requiring both is essential:
|
||||||
|
/// never release swayidle's delay inhibitor on the synthetic notification.
|
||||||
|
/// Re-audit iced/winit's session_lock Action::Lock and SessionLockHandler when
|
||||||
|
/// updating libcosmic. A missing callback times out closed, never signals ready.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Confirmation {
|
||||||
|
events: u8,
|
||||||
|
}
|
||||||
|
impl Confirmation {
|
||||||
|
pub fn observe(&mut self) -> bool {
|
||||||
|
self.events = self.events.saturating_add(1);
|
||||||
|
self.events == 2
|
||||||
|
}
|
||||||
|
pub fn confirmed(&self) -> bool {
|
||||||
|
self.events >= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn synthetic_event_does_not_confirm_a_lock() {
|
||||||
|
let mut c = Confirmation::default();
|
||||||
|
assert!(!c.confirmed());
|
||||||
|
assert!(!c.observe());
|
||||||
|
assert!(!c.confirmed());
|
||||||
|
assert!(c.observe());
|
||||||
|
assert!(c.confirmed());
|
||||||
|
assert!(!c.observe());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ enum Message {
|
|||||||
Undo,
|
Undo,
|
||||||
Preset(usize),
|
Preset(usize),
|
||||||
PickWallpaper,
|
PickWallpaper,
|
||||||
|
PreviewLock,
|
||||||
|
LockPreviewStarted(Result<(), String>),
|
||||||
AddNote,
|
AddNote,
|
||||||
NoteAction(String, widget::text_editor::Action),
|
NoteAction(String, widget::text_editor::Action),
|
||||||
SaveNote(String, u64),
|
SaveNote(String, u64),
|
||||||
@@ -248,6 +250,35 @@ impl cosmic::Application for App {
|
|||||||
}
|
}
|
||||||
Err(error) => self.error = Some(format!("Config reload failed: {error}")),
|
Err(error) => self.error = Some(format!("Config reload failed: {error}")),
|
||||||
},
|
},
|
||||||
|
Message::PreviewLock => {
|
||||||
|
let path = self.path.clone();
|
||||||
|
return Task::perform(
|
||||||
|
async move {
|
||||||
|
let binary = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|p| p.join("ferese-lock")))
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
.unwrap_or_else(|| "ferese-lock".into());
|
||||||
|
std::process::Command::new(binary)
|
||||||
|
.arg("--preview")
|
||||||
|
.arg("--config")
|
||||||
|
.arg(path)
|
||||||
|
.spawn()
|
||||||
|
.map(|mut child| {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("Could not open the lock preview: {error}"))
|
||||||
|
},
|
||||||
|
|result| cosmic::Action::App(Message::LockPreviewStarted(result)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Message::LockPreviewStarted(result) => {
|
||||||
|
if let Err(error) = result {
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Message::Page(page) => {
|
Message::Page(page) => {
|
||||||
self.page = page;
|
self.page = page;
|
||||||
self.search.clear();
|
self.search.clear();
|
||||||
@@ -671,6 +702,13 @@ impl cosmic::Application for App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match self.page {
|
match self.page {
|
||||||
|
Page::LockScreen => {
|
||||||
|
body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."));
|
||||||
|
body = body.push(
|
||||||
|
button::standard("Preview lock screen").on_press(Message::PreviewLock),
|
||||||
|
);
|
||||||
|
body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."));
|
||||||
|
}
|
||||||
Page::Desktop => {
|
Page::Desktop => {
|
||||||
let can_change_list =
|
let can_change_list =
|
||||||
!self.saving && !self.note_editors.values().any(|e| e.dirty);
|
!self.saving && !self.note_editors.values().any(|e| e.dirty);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub enum Page {
|
|||||||
Desktop,
|
Desktop,
|
||||||
Bar,
|
Bar,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
LockScreen,
|
||||||
Windows,
|
Windows,
|
||||||
Motion,
|
Motion,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
@@ -14,12 +15,13 @@ pub enum Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Page {
|
impl Page {
|
||||||
pub const ALL: [Self; 11] = [
|
pub const ALL: [Self; 12] = [
|
||||||
Self::Appearance,
|
Self::Appearance,
|
||||||
Self::Wallpaper,
|
Self::Wallpaper,
|
||||||
Self::Desktop,
|
Self::Desktop,
|
||||||
Self::Bar,
|
Self::Bar,
|
||||||
Self::Notifications,
|
Self::Notifications,
|
||||||
|
Self::LockScreen,
|
||||||
Self::Windows,
|
Self::Windows,
|
||||||
Self::Motion,
|
Self::Motion,
|
||||||
Self::Keyboard,
|
Self::Keyboard,
|
||||||
@@ -35,6 +37,7 @@ impl Page {
|
|||||||
Self::Desktop => "Desktop widgets",
|
Self::Desktop => "Desktop widgets",
|
||||||
Self::Bar => "Menu bar",
|
Self::Bar => "Menu bar",
|
||||||
Self::Notifications => "Notifications",
|
Self::Notifications => "Notifications",
|
||||||
|
Self::LockScreen => "Lock screen",
|
||||||
Self::Windows => "Windows",
|
Self::Windows => "Windows",
|
||||||
Self::Motion => "Motion",
|
Self::Motion => "Motion",
|
||||||
Self::Keyboard => "Keyboard & mouse",
|
Self::Keyboard => "Keyboard & mouse",
|
||||||
@@ -51,6 +54,7 @@ impl Page {
|
|||||||
Self::Desktop => "A clock that feels at home on your wallpaper.",
|
Self::Desktop => "A clock that feels at home on your wallpaper.",
|
||||||
Self::Bar => "Everything you need, within reach.",
|
Self::Bar => "Everything you need, within reach.",
|
||||||
Self::Notifications => "Stay informed on your terms.",
|
Self::Notifications => "Stay informed on your terms.",
|
||||||
|
Self::LockScreen => "Your desktop, safely put away.",
|
||||||
Self::Windows => "Make room for the way you work.",
|
Self::Windows => "Make room for the way you work.",
|
||||||
Self::Motion => "Find your rhythm.",
|
Self::Motion => "Find your rhythm.",
|
||||||
Self::Keyboard => "Fine-tune the everyday details.",
|
Self::Keyboard => "Fine-tune the everyday details.",
|
||||||
@@ -69,6 +73,7 @@ impl Page {
|
|||||||
Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2",
|
Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2",
|
||||||
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
|
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
|
||||||
Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4",
|
Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4",
|
||||||
|
Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3",
|
||||||
Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
|
Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
|
||||||
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
|
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
|
||||||
Self::Keyboard => {
|
Self::Keyboard => {
|
||||||
@@ -544,6 +549,49 @@ pub fn note_fields(index: usize) -> Vec<Field> {
|
|||||||
|
|
||||||
pub fn fields(page: Page) -> Vec<Field> {
|
pub fn fields(page: Page) -> Vec<Field> {
|
||||||
match page {
|
match page {
|
||||||
|
Page::LockScreen => vec![
|
||||||
|
toggle(
|
||||||
|
"lock_screen.show_clock",
|
||||||
|
"Show clock",
|
||||||
|
"Display the time above the sign-in card.",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
toggle(
|
||||||
|
"lock_screen.show_date",
|
||||||
|
"Show date",
|
||||||
|
"Display the weekday and date.",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
choice(
|
||||||
|
"lock_screen.clock_format",
|
||||||
|
"Clock format",
|
||||||
|
"Use a 12-hour or 24-hour clock.",
|
||||||
|
"24h",
|
||||||
|
&[("24h", "24-hour"), ("12h", "12-hour")],
|
||||||
|
),
|
||||||
|
range(
|
||||||
|
"lock_screen.background_blur",
|
||||||
|
"Wallpaper softness",
|
||||||
|
"Blur the lock screen wallpaper. Zero keeps the original image sharp.",
|
||||||
|
18.0,
|
||||||
|
0.0,
|
||||||
|
40.0,
|
||||||
|
1.0,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
range(
|
||||||
|
"lock_screen.background_dim",
|
||||||
|
"Wallpaper dimming",
|
||||||
|
"Darken the wallpaper behind the clock and sign-in card.",
|
||||||
|
0.48,
|
||||||
|
0.0,
|
||||||
|
0.9,
|
||||||
|
0.02,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
],
|
||||||
Page::Appearance => vec![
|
Page::Appearance => vec![
|
||||||
range(
|
range(
|
||||||
"theme.geometry.shell_radius",
|
"theme.geometry.shell_radius",
|
||||||
|
|||||||
Reference in new issue
Block a user