Initial project setup with core modules
Moonlock lockscreen scaffolding: PAM auth (ctypes), fprintd D-Bus listener, i18n (DE/EN), user detection, power actions. 33 tests passing.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# ABOUTME: Package init for moonlock — a secure Wayland lockscreen.
|
||||
# ABOUTME: Uses ext-session-lock-v1, PAM auth and fprintd fingerprint support.
|
||||
@@ -0,0 +1,111 @@
|
||||
# ABOUTME: PAM authentication via ctypes wrapper around libpam.so.
|
||||
# ABOUTME: Provides authenticate(username, password) for the lockscreen.
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
from ctypes import POINTER, Structure, c_char_p, c_int, c_void_p, CFUNCTYPE, pointer
|
||||
|
||||
# PAM return codes
|
||||
PAM_SUCCESS = 0
|
||||
PAM_AUTH_ERR = 7
|
||||
PAM_PROMPT_ECHO_OFF = 1
|
||||
|
||||
|
||||
class PamMessage(Structure):
|
||||
"""PAM message structure (pam_message)."""
|
||||
|
||||
_fields_ = [
|
||||
("msg_style", c_int),
|
||||
("msg", c_char_p),
|
||||
]
|
||||
|
||||
|
||||
class PamResponse(Structure):
|
||||
"""PAM response structure (pam_response)."""
|
||||
|
||||
_fields_ = [
|
||||
("resp", c_char_p),
|
||||
("resp_retcode", c_int),
|
||||
]
|
||||
|
||||
|
||||
# PAM conversation callback type
|
||||
PamConvFunc = CFUNCTYPE(
|
||||
c_int,
|
||||
c_int,
|
||||
POINTER(POINTER(PamMessage)),
|
||||
POINTER(POINTER(PamResponse)),
|
||||
c_void_p,
|
||||
)
|
||||
|
||||
|
||||
class PamConv(Structure):
|
||||
"""PAM conversation structure (pam_conv)."""
|
||||
|
||||
_fields_ = [
|
||||
("conv", PamConvFunc),
|
||||
("appdata_ptr", c_void_p),
|
||||
]
|
||||
|
||||
|
||||
def _get_libpam() -> ctypes.CDLL:
|
||||
"""Load and return the libpam shared library."""
|
||||
pam_path = ctypes.util.find_library("pam")
|
||||
if not pam_path:
|
||||
raise OSError("libpam not found")
|
||||
return ctypes.CDLL(pam_path)
|
||||
|
||||
|
||||
def _make_conv_func(password: str) -> PamConvFunc:
|
||||
"""Create a PAM conversation callback that provides the password."""
|
||||
|
||||
def _conv(
|
||||
num_msg: int,
|
||||
msg: POINTER(POINTER(PamMessage)),
|
||||
resp: POINTER(POINTER(PamResponse)),
|
||||
appdata_ptr: c_void_p,
|
||||
) -> int:
|
||||
# Allocate response array
|
||||
response = (PamResponse * num_msg)()
|
||||
for i in range(num_msg):
|
||||
response[i].resp = password.encode("utf-8")
|
||||
response[i].resp_retcode = 0
|
||||
# PAM expects malloc'd memory; ctypes handles this via the array
|
||||
resp[0] = ctypes.cast(response, POINTER(PamResponse))
|
||||
return PAM_SUCCESS
|
||||
|
||||
return PamConvFunc(_conv)
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> bool:
|
||||
"""Authenticate a user via PAM. Returns True on success, False otherwise."""
|
||||
libpam = _get_libpam()
|
||||
|
||||
# Set up conversation
|
||||
conv_func = _make_conv_func(password)
|
||||
conv = PamConv(conv=conv_func, appdata_ptr=None)
|
||||
|
||||
# PAM handle
|
||||
handle = c_void_p()
|
||||
|
||||
# Start PAM session
|
||||
ret = libpam.pam_start(
|
||||
b"moonlock",
|
||||
username.encode("utf-8"),
|
||||
pointer(conv),
|
||||
pointer(handle),
|
||||
)
|
||||
if ret != PAM_SUCCESS:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Authenticate
|
||||
ret = libpam.pam_authenticate(handle, 0)
|
||||
if ret != PAM_SUCCESS:
|
||||
return False
|
||||
|
||||
# Check account validity
|
||||
ret = libpam.pam_acct_mgmt(handle, 0)
|
||||
return ret == PAM_SUCCESS
|
||||
finally:
|
||||
libpam.pam_end(handle, ret)
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#PLACEHOLDER" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#222222" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,155 @@
|
||||
# ABOUTME: fprintd D-Bus integration for fingerprint authentication.
|
||||
# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop.
|
||||
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
FPRINTD_BUS_NAME = "net.reactivated.Fprint"
|
||||
FPRINTD_MANAGER_PATH = "/net/reactivated/Fprint/Manager"
|
||||
FPRINTD_MANAGER_IFACE = "net.reactivated.Fprint.Manager"
|
||||
FPRINTD_DEVICE_IFACE = "net.reactivated.Fprint.Device"
|
||||
|
||||
# Retry-able statuses (finger not read properly, try again)
|
||||
_RETRY_STATUSES = {
|
||||
"verify-swipe-too-short",
|
||||
"verify-finger-not-centered",
|
||||
"verify-remove-and-retry",
|
||||
"verify-retry-scan",
|
||||
}
|
||||
|
||||
|
||||
class FingerprintListener:
|
||||
"""Listens for fingerprint verification events via fprintd D-Bus."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._device_proxy: Gio.DBusProxy | None = None
|
||||
self._device_path: str | None = None
|
||||
self._signal_id: int | None = None
|
||||
self._running: bool = False
|
||||
self._on_success: Callable[[], None] | None = None
|
||||
self._on_failure: Callable[[], None] | None = None
|
||||
|
||||
self._init_device()
|
||||
|
||||
def _init_device(self) -> None:
|
||||
"""Connect to fprintd and get the default device."""
|
||||
try:
|
||||
manager = Gio.DBusProxy.new_for_bus_sync(
|
||||
Gio.BusType.SYSTEM,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
FPRINTD_BUS_NAME,
|
||||
FPRINTD_MANAGER_PATH,
|
||||
FPRINTD_MANAGER_IFACE,
|
||||
None,
|
||||
)
|
||||
result = manager.GetDefaultDevice()
|
||||
if result:
|
||||
self._device_path = result
|
||||
self._device_proxy = Gio.DBusProxy.new_for_bus_sync(
|
||||
Gio.BusType.SYSTEM,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
FPRINTD_BUS_NAME,
|
||||
self._device_path,
|
||||
FPRINTD_DEVICE_IFACE,
|
||||
None,
|
||||
)
|
||||
except GLib.Error:
|
||||
self._device_proxy = None
|
||||
self._device_path = None
|
||||
|
||||
def is_available(self, username: str) -> bool:
|
||||
"""Check if fprintd is running and the user has enrolled fingerprints."""
|
||||
if not self._device_proxy:
|
||||
return False
|
||||
try:
|
||||
result = self._device_proxy.ListEnrolledFingers("(s)", username)
|
||||
return bool(result)
|
||||
except GLib.Error:
|
||||
return False
|
||||
|
||||
def start(
|
||||
self,
|
||||
username: str,
|
||||
on_success: Callable[[], None],
|
||||
on_failure: Callable[[], None],
|
||||
) -> None:
|
||||
"""Start listening for fingerprint verification."""
|
||||
if not self._device_proxy:
|
||||
return
|
||||
|
||||
self._on_success = on_success
|
||||
self._on_failure = on_failure
|
||||
self._running = True
|
||||
|
||||
self._device_proxy.Claim("(s)", username)
|
||||
|
||||
# Connect to the VerifyStatus signal
|
||||
self._signal_id = self._device_proxy.connect(
|
||||
"g-signal", self._on_signal
|
||||
)
|
||||
|
||||
self._device_proxy.VerifyStart("(s)", "any")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop listening and release the device."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
if self._device_proxy:
|
||||
if self._signal_id is not None:
|
||||
self._device_proxy.disconnect(self._signal_id)
|
||||
self._signal_id = None
|
||||
|
||||
try:
|
||||
self._device_proxy.VerifyStop()
|
||||
except GLib.Error:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._device_proxy.Release()
|
||||
except GLib.Error:
|
||||
pass
|
||||
|
||||
def _on_signal(
|
||||
self,
|
||||
proxy: Gio.DBusProxy,
|
||||
sender_name: str | None,
|
||||
signal_name: str,
|
||||
parameters: GLib.Variant,
|
||||
) -> None:
|
||||
"""Handle D-Bus signals from the fprintd device."""
|
||||
if signal_name != "VerifyStatus":
|
||||
return
|
||||
|
||||
status = parameters[0]
|
||||
done = parameters[1]
|
||||
self._on_verify_status(status, done)
|
||||
|
||||
def _on_verify_status(self, status: str, done: bool) -> None:
|
||||
"""Process a VerifyStatus signal from fprintd."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if status == "verify-match":
|
||||
if self._on_success:
|
||||
self._on_success()
|
||||
return
|
||||
|
||||
if status in _RETRY_STATUSES:
|
||||
# Retry silently — finger wasn't read properly
|
||||
self._device_proxy.VerifyStart("(s)", "any")
|
||||
return
|
||||
|
||||
if status == "verify-no-match":
|
||||
if self._on_failure:
|
||||
self._on_failure()
|
||||
# Restart verification for another attempt
|
||||
self._device_proxy.VerifyStart("(s)", "any")
|
||||
return
|
||||
@@ -0,0 +1,97 @@
|
||||
# ABOUTME: Locale detection and string lookup for the lockscreen UI.
|
||||
# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_LOCALE_CONF = Path("/etc/locale.conf")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Strings:
|
||||
"""All user-visible strings for the lockscreen UI."""
|
||||
|
||||
# UI labels
|
||||
password_placeholder: str
|
||||
unlock_button: str
|
||||
reboot_tooltip: str
|
||||
shutdown_tooltip: str
|
||||
|
||||
# Fingerprint
|
||||
fingerprint_prompt: str
|
||||
fingerprint_success: str
|
||||
fingerprint_failed: str
|
||||
|
||||
# Error messages
|
||||
auth_failed: str
|
||||
wrong_password: str
|
||||
reboot_failed: str
|
||||
shutdown_failed: str
|
||||
|
||||
# Templates (use .format())
|
||||
faillock_attempts_remaining: str
|
||||
faillock_locked: str
|
||||
|
||||
|
||||
_STRINGS_DE = Strings(
|
||||
password_placeholder="Passwort",
|
||||
unlock_button="Entsperren",
|
||||
reboot_tooltip="Neustart",
|
||||
shutdown_tooltip="Herunterfahren",
|
||||
fingerprint_prompt="Fingerabdruck auflegen zum Entsperren",
|
||||
fingerprint_success="Fingerabdruck erkannt",
|
||||
fingerprint_failed="Fingerabdruck nicht erkannt",
|
||||
auth_failed="Authentifizierung fehlgeschlagen",
|
||||
wrong_password="Falsches Passwort",
|
||||
reboot_failed="Neustart fehlgeschlagen",
|
||||
shutdown_failed="Herunterfahren fehlgeschlagen",
|
||||
faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!",
|
||||
faillock_locked="Konto ist möglicherweise gesperrt",
|
||||
)
|
||||
|
||||
_STRINGS_EN = Strings(
|
||||
password_placeholder="Password",
|
||||
unlock_button="Unlock",
|
||||
reboot_tooltip="Reboot",
|
||||
shutdown_tooltip="Shut down",
|
||||
fingerprint_prompt="Place finger on reader to unlock",
|
||||
fingerprint_success="Fingerprint recognized",
|
||||
fingerprint_failed="Fingerprint not recognized",
|
||||
auth_failed="Authentication failed",
|
||||
wrong_password="Wrong password",
|
||||
reboot_failed="Reboot failed",
|
||||
shutdown_failed="Shutdown failed",
|
||||
faillock_attempts_remaining="{n} attempt(s) remaining before lockout!",
|
||||
faillock_locked="Account may be locked",
|
||||
)
|
||||
|
||||
_LOCALE_MAP: dict[str, Strings] = {
|
||||
"de": _STRINGS_DE,
|
||||
"en": _STRINGS_EN,
|
||||
}
|
||||
|
||||
|
||||
def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
|
||||
"""Determine the system language from LANG env var or /etc/locale.conf."""
|
||||
lang = os.environ.get("LANG")
|
||||
|
||||
if not lang and locale_conf_path.exists():
|
||||
for line in locale_conf_path.read_text().splitlines():
|
||||
if line.startswith("LANG="):
|
||||
lang = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
if not lang or lang in ("C", "POSIX"):
|
||||
return "en"
|
||||
|
||||
# Extract language prefix: "de_DE.UTF-8" → "de"
|
||||
lang = lang.split("_")[0].split(".")[0]
|
||||
return lang
|
||||
|
||||
|
||||
def load_strings(locale: str | None = None) -> Strings:
|
||||
"""Return the string table for the given locale, defaulting to English."""
|
||||
if locale is None:
|
||||
locale = detect_locale()
|
||||
return _LOCALE_MAP.get(locale, _STRINGS_EN)
|
||||
@@ -0,0 +1,14 @@
|
||||
# ABOUTME: Power actions — reboot and shutdown via loginctl.
|
||||
# ABOUTME: Simple wrappers around system commands for the lockscreen UI.
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def reboot() -> None:
|
||||
"""Reboot the system via loginctl."""
|
||||
subprocess.run(["loginctl", "reboot"], check=True)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Shut down the system via loginctl."""
|
||||
subprocess.run(["loginctl", "poweroff"], check=True)
|
||||
@@ -0,0 +1,58 @@
|
||||
# ABOUTME: Current user detection and avatar loading for the lockscreen.
|
||||
# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
|
||||
|
||||
import os
|
||||
import pwd
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class User:
|
||||
"""Represents the current user for the lockscreen."""
|
||||
|
||||
username: str
|
||||
display_name: str
|
||||
home: Path
|
||||
uid: int
|
||||
|
||||
|
||||
def get_current_user() -> User:
|
||||
"""Get the currently logged-in user's info from the system."""
|
||||
username = os.getlogin()
|
||||
pw = pwd.getpwnam(username)
|
||||
|
||||
gecos = pw.pw_gecos
|
||||
# GECOS field may contain comma-separated values; first field is the full name
|
||||
display_name = gecos.split(",")[0] if gecos else username
|
||||
if not display_name:
|
||||
display_name = username
|
||||
|
||||
return User(
|
||||
username=pw.pw_name,
|
||||
display_name=display_name,
|
||||
home=Path(pw.pw_dir),
|
||||
uid=pw.pw_uid,
|
||||
)
|
||||
|
||||
|
||||
def get_avatar_path(
|
||||
home: Path,
|
||||
username: str | None = None,
|
||||
accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR,
|
||||
) -> Path | None:
|
||||
"""Find the user's avatar image, checking ~/.face then AccountsService."""
|
||||
# ~/.face takes priority
|
||||
face = home / ".face"
|
||||
if face.exists():
|
||||
return face
|
||||
|
||||
# AccountsService icon
|
||||
if username and accountsservice_dir.exists():
|
||||
icon = accountsservice_dir / username
|
||||
if icon.exists():
|
||||
return icon
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user