Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,89 +1,490 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Fingerprint state and the one privileged switch, for the Users page.
|
||||
#
|
||||
# Two independent facts make a working fingerprint login, and conflating them
|
||||
# is how the feature usually confuses people: fprintd must hold at least one
|
||||
# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off
|
||||
# to it), and PAM must be told to ask the reader at all, which on Fedora is
|
||||
# authselect's `with-fingerprint` feature. This helper reports both and can
|
||||
# flip the second.
|
||||
#
|
||||
# Usage:
|
||||
# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""}
|
||||
# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
|
||||
#
|
||||
# authselect is baseline Fedora (it manages PAM for the whole install), and
|
||||
# fprintd ships with Workstation; a machine with neither simply reports no
|
||||
# reader, which hides the card.
|
||||
"""Fingerprint login: the enrolled prints, and the one privileged switch.
|
||||
|
||||
set -uo pipefail
|
||||
Two independent facts make a working fingerprint login, and conflating them is
|
||||
how the feature usually confuses people. fprintd must hold at least one enrolled
|
||||
print, and PAM must be told to ask the reader at all, which on Fedora is
|
||||
authselect's `with-fingerprint` feature. This helper reports both, enrolls and
|
||||
removes prints, and can flip the second.
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
Both facts are reported unconditionally, including on a machine with no reader.
|
||||
The state that used to be invisible -- the feature switched on with nothing
|
||||
enrolled and no reader attached -- is exactly the state someone needs to see and
|
||||
turn off, and reporting `pamEnabled: false` because there was no reader to ask
|
||||
made it unreachable.
|
||||
|
||||
emit() {
|
||||
jq -cn \
|
||||
--argjson reader "$1" \
|
||||
--arg readerName "$2" \
|
||||
--argjson enrolled "$3" \
|
||||
--argjson pamEnabled "$4" \
|
||||
--arg error "$5" \
|
||||
'{reader: $reader, readerName: $readerName, enrolled: $enrolled,
|
||||
pamEnabled: $pamEnabled, error: $error}'
|
||||
}
|
||||
Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than
|
||||
handing the person to GNOME's Users panel: Claim, EnrollStart, then one
|
||||
`EnrollStatus` signal per touch until the device says it is done. Progress is
|
||||
printed as one JSON object per line, the same streaming shape panama-dictate's
|
||||
setup uses, so the page can count touches while they happen. That signal loop is
|
||||
why this is Python and no longer bash -- `status` and `set-unlock` still shell
|
||||
out to exactly the same tools, and answer in exactly the same shapes.
|
||||
|
||||
cmd_status() {
|
||||
command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; }
|
||||
panama-fingerprint status
|
||||
panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
|
||||
panama-fingerprint enroll FINGER (streams {stage,done,total,result})
|
||||
panama-fingerprint remove FINGER
|
||||
panama-fingerprint remove-all
|
||||
|
||||
# fprintd-list both answers "is there a reader" (fprintd is bus-activated,
|
||||
# so this also copes with the daemon not running yet) and names the
|
||||
# enrolled fingers in one call.
|
||||
local listing
|
||||
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
|
||||
# translated daemon would turn every readerless non-English machine into
|
||||
# a permanent error card.
|
||||
if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then
|
||||
# "No devices available" is the normal no-reader machine; anything
|
||||
# else is a real problem worth surfacing.
|
||||
if grep -qi 'no devices' <<<"$listing"; then
|
||||
emit false "" '[]' false ""
|
||||
else
|
||||
emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")"
|
||||
fi
|
||||
authselect is baseline Fedora (it manages PAM for the whole install) and fprintd
|
||||
ships with Workstation; a machine with neither reports no reader and no feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
FPRINT = "net.reactivated.Fprint"
|
||||
MANAGER_PATH = "/net/reactivated/Fprint/Manager"
|
||||
MANAGER_INTERFACE = "net.reactivated.Fprint.Manager"
|
||||
DEVICE_INTERFACE = "net.reactivated.Fprint.Device"
|
||||
|
||||
# The authselect feature that decides whether PAM asks the reader at unlock.
|
||||
FEATURE = "with-fingerprint"
|
||||
|
||||
# How long a reader may sit waiting for a finger before enrollment is given up
|
||||
# on. The page has a Cancel button; this is only for a session left open.
|
||||
IDLE_TIMEOUT_SECONDS = 90
|
||||
|
||||
# A canned fprintd, for contract runs. Points at a JSON file; see replay() for
|
||||
# the shape. Every call that would have gone to the bus is appended to
|
||||
# PANAMA_FINGERPRINT_LOG instead, so a test can pin the order of
|
||||
# Claim / EnrollStart / EnrollStop / Release without a reader in the room.
|
||||
FIXTURE_ENV = "PANAMA_FINGERPRINT_FIXTURE"
|
||||
LOG_ENV = "PANAMA_FINGERPRINT_LOG"
|
||||
|
||||
PANAMA_PATH = os.environ.get("PANAMA_PATH") or os.path.expanduser("~/.local/share/Panama")
|
||||
|
||||
# fprintd's own vocabulary, only far enough to reject nonsense before it becomes
|
||||
# a D-Bus error. What a finger is CALLED is presented by services/Fingerprint.qml,
|
||||
# which is the single place that decides how these read.
|
||||
FINGER = re.compile(r"^(left|right)-(thumb|(index|middle|ring|little)-finger)$")
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible failure: no reader, a refused claim, a bad finger name."""
|
||||
|
||||
|
||||
# ── status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def unlock_feature_enabled() -> bool:
|
||||
"""Whether PAM has been told to ask the reader.
|
||||
|
||||
Asked unconditionally. This is a property of the PAM configuration and has
|
||||
nothing to do with whether a reader is plugged in, which is the whole point:
|
||||
the feature left on with no reader is a state someone has to be able to see.
|
||||
"""
|
||||
if not shutil.which("authselect"):
|
||||
return False
|
||||
try:
|
||||
current = subprocess.run(["authselect", "current"], capture_output=True,
|
||||
text=True, timeout=10, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return FEATURE in (current.stdout or "")
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
reader, name, enrolled, error = False, "", [], ""
|
||||
|
||||
if shutil.which("fprintd-list"):
|
||||
# fprintd-list answers "is there a reader" (fprintd is bus-activated, so
|
||||
# this copes with the daemon not running yet) and names the enrolled
|
||||
# fingers in one call.
|
||||
#
|
||||
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
|
||||
# translated daemon would turn every readerless non-English machine into
|
||||
# a permanent error card.
|
||||
environment = dict(os.environ, LC_ALL="C")
|
||||
try:
|
||||
listing = subprocess.run(["fprintd-list", os.environ.get("USER", "")],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
check=False, env=environment)
|
||||
output = (listing.stdout or "") + (listing.stderr or "")
|
||||
except (OSError, subprocess.SubprocessError) as failure:
|
||||
listing, output = None, str(failure)
|
||||
|
||||
if listing is None or listing.returncode != 0:
|
||||
# "No devices available" is the normal no-reader machine; anything
|
||||
# else is a real problem worth surfacing.
|
||||
if "no devices" not in output.lower():
|
||||
first = next((line for line in output.splitlines() if line.strip()), "")
|
||||
error = f"fprintd did not answer: {first}"
|
||||
else:
|
||||
reader = True
|
||||
# "Fingerprints for user gib on FocalTech ... (press):" carries the
|
||||
# reader product name; " - #0: right-index-finger" the enrollment.
|
||||
match = re.search(r"^Fingerprints for user \S+ on (.*) \(\w*\):$",
|
||||
output, re.MULTILINE)
|
||||
name = match.group(1) if match else ""
|
||||
enrolled = re.findall(r"^ *- #\d+: (.+)$", output, re.MULTILINE)
|
||||
|
||||
feature = unlock_feature_enabled()
|
||||
return {
|
||||
"reader": reader,
|
||||
"readerName": name,
|
||||
"enrolled": enrolled,
|
||||
# Two names for one fact, on purpose: `pamEnabled` is what this helper
|
||||
# has always called it, `unlockFeatureEnabled` is what it is.
|
||||
"pamEnabled": feature,
|
||||
"unlockFeatureEnabled": feature,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
# ── the privileged switch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def set_unlock(state: str) -> int:
|
||||
if state == "on":
|
||||
verb = "enable-feature"
|
||||
reason = ("Turning on fingerprint login: telling PAM (via authselect) to "
|
||||
"ask the fingerprint reader when unlocking")
|
||||
elif state == "off":
|
||||
verb = "disable-feature"
|
||||
reason = ("Turning off fingerprint login: telling PAM (via authselect) to "
|
||||
"stop asking the fingerprint reader")
|
||||
else:
|
||||
print("panama-fingerprint set-unlock takes on|off", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
escalate = os.path.join(PANAMA_PATH, "bin", "panama-sudo")
|
||||
prefix = ([escalate, "--reason", reason, "--"]
|
||||
if os.access(escalate, os.X_OK) else ["sudo"])
|
||||
return subprocess.run(prefix + ["authselect", verb, FEATURE], check=False).returncode
|
||||
|
||||
|
||||
# ── talking to fprintd ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def emit(**fields) -> None:
|
||||
"""One JSON object, one line, flushed. The page reads these as they arrive."""
|
||||
print(json.dumps(fields, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def log_call(method: str, *arguments) -> None:
|
||||
"""Record a call the fixture stood in for, so a contract can pin the order."""
|
||||
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
|
||||
if not path:
|
||||
return
|
||||
fi
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps({"method": method, "arguments": list(arguments)},
|
||||
separators=(",", ":")) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# "Fingerprints for user gib on FocalTech ... (press):" carries the reader
|
||||
# product name; " - #0: right-index-finger" lines carry the enrollment.
|
||||
local name enrolled pam
|
||||
name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)"
|
||||
enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')"
|
||||
pam=false
|
||||
authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true
|
||||
|
||||
emit true "$name" "$enrolled" "$pam" ""
|
||||
}
|
||||
def fixture() -> dict | None:
|
||||
"""The canned fprintd, or None when there is a real bus to talk to.
|
||||
|
||||
cmd_set_unlock() {
|
||||
local verb reason
|
||||
case "$1" in
|
||||
on) verb=enable-feature
|
||||
reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;;
|
||||
off) verb=disable-feature
|
||||
reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;;
|
||||
*) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;;
|
||||
esac
|
||||
{"enrollStages": 5,
|
||||
"results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...],
|
||||
"error": ""} <- non-empty stands in for a refused claim
|
||||
"""
|
||||
path = os.environ.get(FIXTURE_ENV)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError) as failure:
|
||||
raise BoundaryError(f"The fingerprint fixture could not be read: {failure}")
|
||||
|
||||
local sudo_cmd=(sudo)
|
||||
[[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=(
|
||||
"$PANAMA_PATH/bin/panama-sudo" --reason "$reason" --
|
||||
)
|
||||
"${sudo_cmd[@]}" authselect "$verb" with-fingerprint
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
status) cmd_status ;;
|
||||
set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; }
|
||||
cmd_set_unlock "$2" ;;
|
||||
*) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;;
|
||||
esac
|
||||
def bus():
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||
except Exception as failure: # noqa: BLE001 - no bus is a legitimate state
|
||||
raise BoundaryError("The fingerprint service is not answering.") from failure
|
||||
|
||||
|
||||
def call(path: str, interface: str, method: str, parameters=None, reply=None):
|
||||
Gio, GLib, connection = bus()
|
||||
try:
|
||||
result = connection.call_sync(
|
||||
FPRINT, path, interface, method, parameters,
|
||||
GLib.VariantType(reply) if reply else None,
|
||||
Gio.DBusCallFlags.NONE, 30000, None)
|
||||
except Exception as failure: # noqa: BLE001
|
||||
raise BoundaryError(_clean(str(failure))) from failure
|
||||
return result.unpack() if result is not None else None
|
||||
|
||||
|
||||
def _clean(message: str) -> str:
|
||||
"""The useful sentence out of a D-Bus error, without the type prefix."""
|
||||
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip()
|
||||
if "no devices" in trimmed.lower():
|
||||
return "No fingerprint reader is connected."
|
||||
if "permission denied" in trimmed.lower() or "not authorized" in trimmed.lower():
|
||||
return "That was not authorized."
|
||||
if "already in use" in trimmed.lower() or "claimed" in trimmed.lower():
|
||||
return "The fingerprint reader is busy with something else."
|
||||
return trimmed.splitlines()[0][:200] if trimmed else "The fingerprint reader failed."
|
||||
|
||||
|
||||
def default_device() -> str:
|
||||
return call(MANAGER_PATH, MANAGER_INTERFACE, "GetDefaultDevice", None, "(o)")[0]
|
||||
|
||||
|
||||
def enroll_stages(device: str) -> int:
|
||||
Gio, GLib, connection = bus()
|
||||
try:
|
||||
result = connection.call_sync(
|
||||
FPRINT, device, "org.freedesktop.DBus.Properties", "Get",
|
||||
GLib.Variant("(ss)", (DEVICE_INTERFACE, "num-enroll-stages")),
|
||||
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 10000, None)
|
||||
return max(1, int(result.unpack()[0]))
|
||||
except Exception: # noqa: BLE001 - a device that will not say is not fatal
|
||||
# Readers overwhelmingly want five touches, and a counter that is wrong
|
||||
# is better than a page with no counter at all.
|
||||
return 5
|
||||
|
||||
|
||||
# ── enroll ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# fprintd says "enroll-stage-passed" for a touch that counted and
|
||||
# "enroll-completed" when there are no more to take. Everything else beginning
|
||||
# "enroll-retry" or naming a placement problem is a touch to do again, and the
|
||||
# terminal failures arrive with done=true.
|
||||
STAGE_PASSED = "enroll-stage-passed"
|
||||
COMPLETED = "enroll-completed"
|
||||
|
||||
# The results that end an enrollment badly. The device says so itself over the
|
||||
# wire (the signal's `done` flag), so this list is only what the canned fprintd
|
||||
# has to recognize on its own.
|
||||
TERMINAL_FAILURES = (
|
||||
"enroll-failed", "enroll-data-full", "enroll-disconnected",
|
||||
"enroll-duplicate", "enroll-unknown-error",
|
||||
)
|
||||
|
||||
|
||||
def enroll(finger: str) -> int:
|
||||
if not FINGER.fullmatch(finger or ""):
|
||||
raise BoundaryError("That is not a finger fprintd knows.")
|
||||
|
||||
canned = fixture()
|
||||
if canned is not None:
|
||||
return enroll_replay(finger, canned)
|
||||
|
||||
device = default_device()
|
||||
total = enroll_stages(device)
|
||||
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
|
||||
|
||||
Gio, GLib, connection = bus()
|
||||
call(device, DEVICE_INTERFACE, "Claim",
|
||||
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
state = {"done": 0, "result": "", "error": "", "ok": False, "seen": time.monotonic()}
|
||||
|
||||
def on_status(_connection, _sender, _path, _interface, _signal, parameters):
|
||||
result, finished = parameters.unpack()
|
||||
state["seen"] = time.monotonic()
|
||||
state["result"] = result
|
||||
if result == STAGE_PASSED:
|
||||
state["done"] = min(state["done"] + 1, total)
|
||||
if finished:
|
||||
state["ok"] = result == COMPLETED
|
||||
if not state["ok"]:
|
||||
state["error"] = describe_result(result)
|
||||
loop.quit()
|
||||
return
|
||||
emit(ok=True, stage="scanning", done=state["done"], total=total,
|
||||
result=result, error="")
|
||||
|
||||
subscription = connection.signal_subscribe(
|
||||
None, DEVICE_INTERFACE, "EnrollStatus", device, None,
|
||||
Gio.DBusSignalFlags.NONE, on_status)
|
||||
|
||||
# A cancelled enrollment is a terminated process -- the page drops the
|
||||
# Process and Quickshell sends a signal. The device must still be released,
|
||||
# or the next attempt finds the reader busy with a session that is gone.
|
||||
def cancelled(_data=None):
|
||||
state["error"] = ""
|
||||
state["result"] = "cancelled"
|
||||
loop.quit()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, cancelled, None)
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, cancelled, None)
|
||||
|
||||
def watchdog(_data=None):
|
||||
if time.monotonic() - state["seen"] > IDLE_TIMEOUT_SECONDS:
|
||||
state["error"] = "The reader stopped answering."
|
||||
loop.quit()
|
||||
return GLib.SOURCE_REMOVE
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
GLib.timeout_add_seconds(5, watchdog, None)
|
||||
|
||||
try:
|
||||
call(device, DEVICE_INTERFACE, "EnrollStart", GLib.Variant("(s)", (finger,)))
|
||||
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
|
||||
loop.run()
|
||||
finally:
|
||||
connection.signal_unsubscribe(subscription)
|
||||
# Both are best-effort: the interesting failure already happened, and a
|
||||
# reader left claimed is worse than a second error nobody can act on.
|
||||
for method in ("EnrollStop", "Release"):
|
||||
try:
|
||||
call(device, DEVICE_INTERFACE, method)
|
||||
except BoundaryError:
|
||||
pass
|
||||
|
||||
return finish(state, total)
|
||||
|
||||
|
||||
def finish(state: dict, total: int) -> int:
|
||||
if state["ok"]:
|
||||
emit(ok=True, stage="done", done=total, total=total,
|
||||
result=COMPLETED, error="")
|
||||
return 0
|
||||
stage = "cancelled" if state["result"] == "cancelled" else "failed"
|
||||
emit(ok=False, stage=stage, done=state["done"], total=total,
|
||||
result=state["result"], error=state["error"])
|
||||
return 1
|
||||
|
||||
|
||||
def describe_result(result: str) -> str:
|
||||
"""fprintd's terminal results, in words someone can act on."""
|
||||
return {
|
||||
"enroll-failed": "That finger could not be read. Try enrolling it again.",
|
||||
"enroll-data-full": "The reader has no room for another fingerprint.",
|
||||
"enroll-disconnected": "The fingerprint reader was disconnected.",
|
||||
"enroll-duplicate": "That finger is already enrolled.",
|
||||
"enroll-unknown-error": "The fingerprint reader failed.",
|
||||
}.get(result, "Enrolling that finger did not finish.")
|
||||
|
||||
|
||||
def enroll_replay(finger: str, canned: dict) -> int:
|
||||
"""The same stream, from a file. No bus, no reader, no waiting."""
|
||||
total = max(1, int(canned.get("enrollStages", 5)))
|
||||
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
|
||||
|
||||
log_call("Claim", os.environ.get("USER", ""))
|
||||
refusal = str(canned.get("error") or "")
|
||||
if refusal:
|
||||
emit(ok=False, stage="failed", done=0, total=total, result="", error=refusal)
|
||||
return 1
|
||||
|
||||
log_call("EnrollStart", finger)
|
||||
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
|
||||
|
||||
state = {"done": 0, "result": "", "error": "", "ok": False}
|
||||
for result in canned.get("results", []):
|
||||
state["result"] = result
|
||||
if result == STAGE_PASSED:
|
||||
state["done"] = min(state["done"] + 1, total)
|
||||
if result == COMPLETED or result in TERMINAL_FAILURES:
|
||||
state["ok"] = result == COMPLETED
|
||||
if not state["ok"]:
|
||||
state["error"] = describe_result(result)
|
||||
break
|
||||
emit(ok=True, stage="scanning", done=state["done"], total=total,
|
||||
result=result, error="")
|
||||
|
||||
log_call("EnrollStop")
|
||||
log_call("Release")
|
||||
return finish(state, total)
|
||||
|
||||
|
||||
# ── remove ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def remove(finger: str | None) -> dict:
|
||||
"""Delete one enrolled finger, or all of them, and report the fresh state."""
|
||||
if finger is not None and not FINGER.fullmatch(finger):
|
||||
raise BoundaryError("That is not a finger fprintd knows.")
|
||||
|
||||
canned = fixture()
|
||||
if canned is not None:
|
||||
log_call("Claim", os.environ.get("USER", ""))
|
||||
if finger is None:
|
||||
log_call("DeleteEnrolledFingers2")
|
||||
else:
|
||||
log_call("DeleteEnrolledFinger", finger)
|
||||
log_call("Release")
|
||||
return {**status(), "error": str(canned.get("error") or "")}
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
device = default_device()
|
||||
call(device, DEVICE_INTERFACE, "Claim",
|
||||
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
|
||||
try:
|
||||
if finger is None:
|
||||
# DeleteEnrolledFingers2 works on the claimed user; its predecessor
|
||||
# took a name and is deprecated for exactly the confusion that
|
||||
# invited -- deleting someone else's prints by typo.
|
||||
call(device, DEVICE_INTERFACE, "DeleteEnrolledFingers2")
|
||||
else:
|
||||
call(device, DEVICE_INTERFACE, "DeleteEnrolledFinger",
|
||||
GLib.Variant("(s)", (finger,)))
|
||||
finally:
|
||||
try:
|
||||
call(device, DEVICE_INTERFACE, "Release")
|
||||
except BoundaryError:
|
||||
pass
|
||||
|
||||
return status()
|
||||
|
||||
|
||||
# ── entry ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
verb = arguments[0] if arguments else ""
|
||||
|
||||
try:
|
||||
if verb == "status" and len(arguments) == 1:
|
||||
print(json.dumps(status(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if verb == "set-unlock" and len(arguments) == 2:
|
||||
return set_unlock(arguments[1])
|
||||
|
||||
if verb == "enroll" and len(arguments) == 2:
|
||||
return enroll(arguments[1])
|
||||
|
||||
if verb == "remove" and len(arguments) == 2:
|
||||
print(json.dumps(remove(arguments[1]), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if verb == "remove-all" and len(arguments) == 1:
|
||||
print(json.dumps(remove(None), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
except BoundaryError as failure:
|
||||
# Enrollment streams, so its failure has to arrive in the same shape as
|
||||
# its progress; the others answer with the state plus the message, so a
|
||||
# page never has to ask twice to find out what happened.
|
||||
if verb == "enroll":
|
||||
emit(ok=False, stage="failed", done=0, total=0, result="",
|
||||
error=str(failure))
|
||||
else:
|
||||
print(json.dumps({**status(), "error": str(failure)},
|
||||
separators=(",", ":")))
|
||||
return 1
|
||||
|
||||
print("usage: panama-fingerprint status | set-unlock on|off | enroll FINGER | "
|
||||
"remove FINGER | remove-all", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
||||
Reference in New Issue
Block a user