diff --git a/AGENTS.md b/AGENTS.md index 08168bc..023e043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ The project is intended for alerting and evidence collection on equipment owned - systemd user services - Wayland session - Motion with a V4L2 camera -- swaylock for the current lock lifecycle +- Noctalia v5 for the current Niri lock lifecycle - Bash scripts Do not claim support for another distribution, compositor, lock screen, camera stack, or init system until it has been tested and documented. @@ -27,7 +27,7 @@ Read `docs/ARCHITECTURE.md` before changing service boundaries. - `vigilia-motion.service` owns camera monitoring. - `vigilia-power.service` independently owns AC power events. - `vigilia-inhibit.service` blocks idle/sleep while armed. -- `vigilia-arm` starts the target and waits for the lock command to exit. +- `vigilia-arm` starts the target and owns the selected lock backend lifecycle. - `motion-event.sh` and `power-watch.sh` append events and call `notify.sh`. Keep camera and power monitoring independent. A failure in notification delivery must not prevent local logging. diff --git a/README.md b/README.md index fce5c24..79ad0d6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vigilia 是一套面向 Linux 笔记本的轻量级离席警戒工具。布防 - ntfy 文本通知与抓拍附件 - 本地 TSV 事件日志和 systemd journal 双重记录 - 布防时阻止自动休眠 -- `swaylock` 解锁后自动撤防 +- Noctalia 锁定状态确认后自动撤防,兼容阻塞式锁屏命令 - 不开放 Motion Web 控制或视频流端口 ## 威胁模型 @@ -25,10 +25,10 @@ Vigilia 用于**发现、告警和留证**,不能阻止具备物理访问权 Arch/CachyOS: ```bash -sudo pacman -S motion v4l-utils swaylock curl libnotify +sudo pacman -S motion v4l-utils noctalia curl libnotify ``` -基础运行还需要 systemd、Bash、util-linux 和 Linux power_supply 接口;这些通常已随系统安装。 +基础运行还需要 systemd、Bash、awk、util-linux 和 Linux power_supply 接口;这些通常已随系统安装。 ## 安装 @@ -136,7 +136,23 @@ vigilia-disarm vigilia-status ``` -默认情况下,`vigilia-arm` 启动监控后运行 `swaylock`;成功解锁时会自动停止所有监控服务。 +默认 Noctalia 后端会先请求 `noctalia msg session lock`,再轮询 `noctalia msg status` 的真实 `locked` 状态。只有先观察到锁定、之后观察到解锁,Vigilia 才会停止监控并记录 `DISARMED reason=unlock backend=noctalia`;IPC 请求命令自行退出绝不等同于解锁。 + +现有用户若已经在 `vigilia.env` 设置了 `LOCK_COMMAND`,安装器会保留它,并自动继续使用旧的阻塞命令后端。阻塞命令必须在真实解锁前保持运行;若它过早退出,Vigilia 会记录 `LOCK_FAILED` 并安全撤防,而不是伪造解锁。 + +可显式选择后端: + +```bash +# Niri + Noctalia v5(默认) +LOCK_BACKEND="noctalia" +NOCTALIA_COMMAND="noctalia" + +# 保留给仍使用前台阻塞锁屏程序的用户 +LOCK_BACKEND="command" +LOCK_COMMAND="swaylock" +``` + +锁定获取超时、Noctalia 状态不可用或阻塞命令异常退出,都会在 `events.tsv` 与 journal 中写入 `LOCK_FAILED`。这些都不是成功解锁。 ### Niri 快捷键 diff --git a/bin/vigilia-arm b/bin/vigilia-arm index d8de126..602ff25 100755 --- a/bin/vigilia-arm +++ b/bin/vigilia-arm @@ -6,6 +6,8 @@ CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}/vigilia" STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}/vigilia" ENV_FILE="$CONFIG_HOME/vigilia.env" LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.lock" +ARM_PID_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.pid" +ARM_START_TIME_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.start-time" EVENT_LOG="$STATE_HOME/events.tsv" mkdir -p "$STATE_HOME" @@ -15,16 +17,162 @@ if [[ -r "$ENV_FILE" ]]; then source "$ENV_FILE" fi +if [[ -n "${LOCK_BACKEND:-}" ]]; then + LOCK_BACKEND="$LOCK_BACKEND" +elif [[ -n "${LOCK_COMMAND:-}" ]]; then + # Existing pre-Noctalia configurations explicitly chose a blocking command. + LOCK_BACKEND="command" +else + LOCK_BACKEND="noctalia" +fi +LOCK_COMMAND="${LOCK_COMMAND:-swaylock}" +LOCK_ACQUIRE_TIMEOUT_SECONDS="${LOCK_ACQUIRE_TIMEOUT_SECONDS:-15}" +LOCK_POLL_INTERVAL_SECONDS="${LOCK_POLL_INTERVAL_SECONDS:-1}" +LOCK_COMMAND_ACQUIRE_DELAY_SECONDS="${LOCK_COMMAND_ACQUIRE_DELAY_SECONDS:-1}" +NOCTALIA_COMMAND="${NOCTALIA_COMMAND:-noctalia}" + +record_event() { + local event="$1" + local detail="${2:-}" + + printf '%s\t%s\t%s\n' "$(date --iso-8601=seconds)" "$event" "$detail" \ + >> "$EVENT_LOG" + logger --tag vigilia -- "$event $detail" +} + +notify_armed() { + "$HOME/.local/share/vigilia/bin/notify.sh" text \ + "Vigilia armed" \ + "Camera monitoring, power monitoring, and sleep inhibition are active." \ + 3 >/dev/null 2>&1 || true +} + +stop_monitoring() { + systemctl --user stop vigilia.target >/dev/null 2>&1 || true +} + +cleanup() { + stop_monitoring + rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE" +} + +lock_status_detail() { + local hint="unknown" + + if [[ -n "${XDG_SESSION_ID:-}" ]] && command -v loginctl >/dev/null 2>&1; then + hint="$(loginctl show-session "$XDG_SESSION_ID" -p LockedHint --value \ + 2>/dev/null || true)" + case "$hint" in + yes|no) ;; + *) hint="unknown" ;; + esac + fi + + printf 'logind_locked_hint=%s' "$hint" +} + +noctalia_locked() { + local status + + status="$("$NOCTALIA_COMMAND" msg status 2>/dev/null)" || return 2 + grep -Eq '"locked"[[:space:]]*:[[:space:]]*true' <<< "$status" +} + +sleep_poll_interval() { + sleep "$LOCK_POLL_INTERVAL_SECONDS" +} + +run_noctalia_backend() { + local deadline acquired=0 status status_unavailable=0 + + if ! "$NOCTALIA_COMMAND" msg session lock >/dev/null 2>&1; then + record_event "LOCK_FAILED" "backend=noctalia reason=lock_request_failed" + return 1 + fi + + deadline=$((SECONDS + LOCK_ACQUIRE_TIMEOUT_SECONDS)) + while ((SECONDS < deadline)); do + if noctalia_locked; then + acquired=1 + record_event "LOCK_ACQUIRED" \ + "backend=noctalia $(lock_status_detail)" + break + else + status=$? + if [[ "$status" -eq 2 ]]; then + status_unavailable=1 + fi + fi + sleep_poll_interval + done + + if (( ! acquired )); then + if (( status_unavailable )); then + record_event "LOCK_FAILED" "backend=noctalia reason=status_unavailable" + else + record_event "LOCK_FAILED" "backend=noctalia reason=acquire_timeout" + fi + return 1 + fi + + while true; do + if noctalia_locked; then + sleep_poll_interval + continue + else + status=$? + if [[ "$status" -eq 2 ]]; then + record_event "LOCK_FAILED" \ + "backend=noctalia reason=status_unavailable_after_acquire" + return 1 + fi + fi + break + done + + record_event "DISARMED" "reason=unlock backend=noctalia $(lock_status_detail)" + return 0 +} + +run_command_backend() { + local lock_status + local -a lock_argv + + read -r -a lock_argv <<< "$LOCK_COMMAND" + "${lock_argv[@]}" & + local lock_pid=$! + + sleep "$LOCK_COMMAND_ACQUIRE_DELAY_SECONDS" + if ! kill -0 "$lock_pid" 2>/dev/null; then + wait "$lock_pid" || lock_status=$? + record_event "LOCK_FAILED" \ + "backend=command reason=command_exited_before_acquire status=${lock_status:-0}" + return 1 + fi + + record_event "LOCK_ACQUIRED" "backend=command" + if ! wait "$lock_pid"; then + lock_status=$? + record_event "LOCK_FAILED" \ + "backend=command reason=command_exited_nonzero status=$lock_status" + return 1 + fi + + record_event "DISARMED" "reason=unlock backend=command" +} + exec 9>"$LOCK_FILE" if ! flock -n 9; then notify-send "Vigilia" "Already armed or waiting for unlock." 2>/dev/null || true exit 1 fi -cleanup() { - systemctl --user stop vigilia.target >/dev/null 2>&1 || true -} -trap cleanup EXIT INT TERM +printf '%s\n' "$$" > "$ARM_PID_FILE" +awk '{ print $22 }' "/proc/$$/stat" > "$ARM_START_TIME_FILE" + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM systemctl --user start vigilia.target @@ -32,31 +180,32 @@ for service in vigilia-motion.service vigilia-power.service; do if ! systemctl --user is-active --quiet "$service"; then notify-send -u critical "Vigilia failed to arm" \ "$service did not start." 2>/dev/null || true + record_event "LOCK_FAILED" "backend=$LOCK_BACKEND reason=monitoring_service_inactive service=$service" exit 1 fi done -printf '%s\tARMED\tservices=motion,power,inhibit\n' \ - "$(date --iso-8601=seconds)" >> "$EVENT_LOG" -logger --tag vigilia -- "ARMED services=motion,power,inhibit" - -"$HOME/.local/share/vigilia/bin/notify.sh" text \ - "Vigilia armed" \ - "Camera monitoring, power monitoring, and sleep inhibition are active." \ - 3 >/dev/null 2>&1 || true - +record_event "ARMED" "services=motion,power,inhibit backend=$LOCK_BACKEND" +notify_armed notify-send "Vigilia" "Armed. Locking the session." 2>/dev/null || true -LOCK_COMMAND="${LOCK_COMMAND:-swaylock}" -read -r -a lock_argv <<< "$LOCK_COMMAND" -"${lock_argv[@]}" - -systemctl --user stop vigilia.target -trap - EXIT +case "$LOCK_BACKEND" in + noctalia) + run_noctalia_backend + ;; + command) + run_command_backend + ;; + *) + record_event "LOCK_FAILED" \ + "backend=$LOCK_BACKEND reason=unsupported_backend" + exit 1 + ;; +esac -printf '%s\tDISARMED\treason=unlock\n' \ - "$(date --iso-8601=seconds)" >> "$EVENT_LOG" -logger --tag vigilia -- "DISARMED reason=unlock" +stop_monitoring +rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE" +trap - EXIT INT TERM "$HOME/.local/share/vigilia/bin/notify.sh" text \ "Vigilia disarmed" \ diff --git a/bin/vigilia-disarm b/bin/vigilia-disarm index 6c4f465..5e0598f 100755 --- a/bin/vigilia-disarm +++ b/bin/vigilia-disarm @@ -3,9 +3,24 @@ set -euo pipefail STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}/vigilia" +ARM_PID_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.pid" +ARM_START_TIME_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.start-time" mkdir -p "$STATE_HOME" systemctl --user stop vigilia.target +if [[ -r "$ARM_PID_FILE" && -r "$ARM_START_TIME_FILE" ]]; then + read -r arm_pid < "$ARM_PID_FILE" || arm_pid="" + read -r arm_start_time < "$ARM_START_TIME_FILE" || arm_start_time="" + if [[ "$arm_pid" =~ ^[0-9]+$ && "$arm_start_time" =~ ^[0-9]+$ ]] && + kill -0 "$arm_pid" 2>/dev/null && + [[ "$(awk '{ print $22 }' "/proc/$arm_pid/stat" 2>/dev/null || true)" == "$arm_start_time" ]]; then + kill -TERM "$arm_pid" || true + else + rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE" + fi +else + rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE" +fi printf '%s\tDISARMED\treason=manual\n' \ "$(date --iso-8601=seconds)" >> "$STATE_HOME/events.tsv" logger --tag vigilia -- "DISARMED reason=manual" diff --git a/config/vigilia.env.example b/config/vigilia.env.example index 7e958d4..0625e30 100644 --- a/config/vigilia.env.example +++ b/config/vigilia.env.example @@ -3,5 +3,14 @@ NTFY_URL="https://ntfy.sh" NTFY_TOPIC="" NTFY_TOKEN="" -# Command that blocks until the session is unlocked. +# Lock lifecycle backend: noctalia waits for `noctalia msg status` to report a +# real lock and later unlock; command keeps the legacy blocking-command path. +LOCK_BACKEND="noctalia" +NOCTALIA_COMMAND="noctalia" +LOCK_ACQUIRE_TIMEOUT_SECONDS=15 +LOCK_POLL_INTERVAL_SECONDS=1 + +# Used only when LOCK_BACKEND="command". The command must remain running until +# real unlock; a command that exits before acquisition fails closed. LOCK_COMMAND="swaylock" +LOCK_COMMAND_ACQUIRE_DELAY_SECONDS=1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 712b8f1..86a28f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,8 +9,9 @@ vigilia-arm │ │ └── power-watch.sh → notify.sh │ └── vigilia-inhibit.service │ └── systemd-inhibit - └── swaylock - └── unlock → systemd --user stop vigilia.target + └── lock backend + ├── Noctalia v5 IPC → observed locked → observed unlocked → stop target + └── blocking command → command exit after acquisition → stop target ``` ## Evidence paths @@ -25,4 +26,5 @@ vigilia-arm - Camera and AC power monitoring are independent services so a power event is still recorded when no motion event occurs. - Web control and live-stream ports are disabled by default. - The project uses user services and does not require a privileged daemon. -- Unlocking ends the blocking lock command, which lets `vigilia-arm` automatically disarm the target. +- The Noctalia backend treats `noctalia msg session lock` as a request only. It disarms after observing `locked=true` and then `locked=false` through `noctalia msg status`; an unavailable or failed state is recorded as `LOCK_FAILED`, never as unlock. +- The command backend retains blocking-lock compatibility, but a command that exits before its acquisition delay is a failed lock request rather than an unlock. diff --git a/docs/TESTING.md b/docs/TESTING.md index f71c30e..7b3381d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -138,7 +138,13 @@ Record whether power is barrel-jack or USB-C PD. Docking stations and multi-sour ## 6. Lock and disarm lifecycle -Arm with: +Confirm the configured backend before arming: + +```bash +grep -E '^(LOCK_BACKEND|LOCK_COMMAND|NOCTALIA_COMMAND)=' ~/.config/vigilia/vigilia.env +``` + +For Niri + Noctalia v5, use `LOCK_BACKEND="noctalia"` and do not substitute `swaylock`. Arm with: ```bash vigilia-arm @@ -149,6 +155,7 @@ Expected while locked: - `vigilia.target`, motion, power, and inhibit services are active; - idle/sleep inhibition is present; - the camera indicator matches Motion activity. +- for Noctalia, an initial `LOCK_ACQUIRED backend=noctalia` event exists before the unlock; `noctalia msg session lock` returning alone is not evidence of locking. Unlock normally. @@ -157,6 +164,7 @@ Expected after unlock: - the target and all three services become inactive; - the camera indicator turns off promptly; - a `DISARMED` event is recorded; +- for Noctalia, `DISARMED reason=unlock backend=noctalia` occurs only after `LOCK_ACQUIRED`; any `LOCK_FAILED` is a failed/cancelled lifecycle, not a pass; - a second `vigilia-disarm` is harmless. Also test explicit disarm from another terminal or trusted remote session while locked. diff --git a/install.sh b/install.sh index 57f5ef6..01650de 100755 --- a/install.sh +++ b/install.sh @@ -10,10 +10,37 @@ BIN_HOME="$HOME/.local/bin" RECORDING_HOME="$HOME/Videos/Vigilia" missing=() -for command in motion v4l2-ctl curl systemd-inhibit flock; do +for command in awk motion v4l2-ctl curl systemd-inhibit flock loginctl; do command -v "$command" >/dev/null 2>&1 || missing+=("$command") done +if [[ -r "$CONFIG_HOME/vigilia.env" ]]; then + # Existing users can retain only a legacy blocking lock command. New or + # explicitly Noctalia-backed configurations must fail before installation + # if the IPC client is unavailable. + lock_backend="$( + awk -F= '/^[[:space:]]*LOCK_BACKEND[[:space:]]*=/ { + value=$2 + sub(/^[[:space:]]*/, "", value) + sub(/[[:space:]]*$/, "", value) + gsub(/^"/, "", value) + gsub(/"$/, "", value) + print value + exit + }' "$CONFIG_HOME/vigilia.env" + )" + lock_command_set="$(awk -F= '/^[[:space:]]*LOCK_COMMAND[[:space:]]*=/ { found=1 } END { print found ? 1 : 0 }' "$CONFIG_HOME/vigilia.env")" +else + lock_backend="noctalia" + lock_command_set=0 +fi + +if [[ "$lock_backend" == "noctalia" || + ( -z "$lock_backend" && "$lock_command_set" -eq 0 ) ]] && + ! command -v noctalia >/dev/null 2>&1; then + missing+=("noctalia") +fi + if ((${#missing[@]} > 0)); then printf 'Vigilia installation is incomplete: missing commands: %s\n' \ "${missing[*]}" >&2 diff --git a/scripts/test-lock-lifecycle.sh b/scripts/test-lock-lifecycle.sh new file mode 100755 index 0000000..d974215 --- /dev/null +++ b/scripts/test-lock-lifecycle.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_TEST"' EXIT + +FAKE_BIN="$TMPDIR_TEST/bin" +CONFIG_HOME="$TMPDIR_TEST/config" +DATA_HOME="$TMPDIR_TEST/data" +STATE_HOME="$TMPDIR_TEST/state" +RUNTIME_HOME="$TMPDIR_TEST/runtime" +SCENARIO_HOME="$TMPDIR_TEST/scenario" +FAKE_SYSTEMCTL_LOG="$SCENARIO_HOME/systemctl.log" +FAKE_NOCTALIA_LOG="$SCENARIO_HOME/noctalia.log" +FAKE_NOCTALIA_STATUS_INDEX="$SCENARIO_HOME/noctalia-status-index" +FAKE_SWAYLOCK_LOG="$SCENARIO_HOME/swaylock.log" +FAKE_NOTIFY_LOG="$SCENARIO_HOME/notify.log" +HOME_ROOT="$TMPDIR_TEST/home" + +mkdir -p "$FAKE_BIN" "$CONFIG_HOME/vigilia" "$DATA_HOME/bin" \ + "$STATE_HOME/vigilia" "$RUNTIME_HOME" "$SCENARIO_HOME" \ + "$HOME_ROOT/.local/share/vigilia/bin" +install -m 755 "$ROOT/bin/vigilia-arm" "$DATA_HOME/bin/vigilia-arm" +install -m 755 "$ROOT/bin/vigilia-disarm" "$DATA_HOME/bin/vigilia-disarm" +install -m 755 "$ROOT/bin/notify.sh" "$DATA_HOME/bin/notify.sh" +install -m 755 "$ROOT/bin/common.sh" "$DATA_HOME/bin/common.sh" + +cat > "$HOME_ROOT/.local/share/vigilia/bin/notify.sh" <<'EOF' +#!/usr/bin/env bash + +printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$FAKE_NOTIFY_LOG" +EOF +chmod 755 "$HOME_ROOT/.local/share/vigilia/bin/notify.sh" + +cat > "$FAKE_BIN/systemctl" <<'EOF' +#!/usr/bin/env bash + +printf '%s\n' "$*" >> "$FAKE_SYSTEMCTL_LOG" + +case "${1:-}" in + is-active) + [[ "${2:-}" == "--quiet" ]] && shift 2 + [[ "${FAKE_SERVICES_ACTIVE:-0}" == 1 ]] + ;; + start|stop) + exit 0 + ;; + *) + exit 0 + ;; +esac +EOF +chmod 755 "$FAKE_BIN/systemctl" + +cat > "$FAKE_BIN/logger" <<'EOF' +#!/usr/bin/env bash + +exit 0 +EOF +chmod 755 "$FAKE_BIN/logger" + +cat > "$FAKE_BIN/notify-send" <<'EOF' +#!/usr/bin/env bash + +exit 0 +EOF +chmod 755 "$FAKE_BIN/notify-send" + +cat > "$FAKE_BIN/noctalia" <<'EOF' +#!/usr/bin/env bash + +if [[ "$1 $2 $3" == "msg session lock" ]]; then + [[ "${FAKE_NOCTALIA_REQUEST_FAIL:-0}" != 1 ]] || exit 1 + printf 'lock-requested\n' >> "$FAKE_NOCTALIA_LOG" + exit 0 +fi + +if [[ "$1 $2" == "msg status" ]]; then + if [[ -n "${FAKE_NOCTALIA_FORCE_UNLOCK_FILE:-}" && + -e "$FAKE_NOCTALIA_FORCE_UNLOCK_FILE" ]]; then + printf '{"locked":false}\n' + exit 0 + fi + index=0 + [[ -r "$FAKE_NOCTALIA_STATUS_INDEX" ]] && index="$(<"$FAKE_NOCTALIA_STATUS_INDEX")" + if [[ -n "${FAKE_NOCTALIA_FAIL_STATUS_AFTER:-}" && + "$index" -ge "$FAKE_NOCTALIA_FAIL_STATUS_AFTER" ]]; then + exit 2 + fi + IFS=, read -r -a states <<< "$FAKE_NOCTALIA_STATES" + if ((index >= ${#states[@]})); then + index=$((${#states[@]} - 1)) + fi + printf '{"locked":%s}\n' "${states[$index]}" + printf '%s\n' "$((index + 1))" > "$FAKE_NOCTALIA_STATUS_INDEX" + exit 0 +fi + +exit 2 +EOF +chmod 755 "$FAKE_BIN/noctalia" + +cat > "$FAKE_BIN/swaylock" <<'EOF' +#!/usr/bin/env bash + +printf 'swaylock-called\n' >> "$FAKE_SWAYLOCK_LOG" +sleep "${FAKE_SWAYLOCK_SLEEP:-0}" +exit 0 +EOF +chmod 755 "$FAKE_BIN/swaylock" + +write_env() { + cat > "$CONFIG_HOME/vigilia/vigilia.env" < "$FAKE_SYSTEMCTL_LOG" + : > "$FAKE_NOCTALIA_LOG" + : > "$FAKE_SWAYLOCK_LOG" + : > "$FAKE_NOTIFY_LOG" + : > "$FAKE_NOCTALIA_STATUS_INDEX" + PATH="$FAKE_BIN:$PATH" \ + HOME="$HOME_ROOT" \ + XDG_CONFIG_HOME="$CONFIG_HOME" \ + XDG_DATA_HOME="$DATA_HOME" \ + XDG_STATE_HOME="$STATE_HOME" \ + XDG_RUNTIME_DIR="$RUNTIME_HOME" \ + FAKE_SYSTEMCTL_LOG="$FAKE_SYSTEMCTL_LOG" \ + FAKE_NOCTALIA_LOG="$FAKE_NOCTALIA_LOG" \ + FAKE_NOCTALIA_STATUS_INDEX="$FAKE_NOCTALIA_STATUS_INDEX" \ + FAKE_SWAYLOCK_LOG="$FAKE_SWAYLOCK_LOG" \ + FAKE_NOTIFY_LOG="$FAKE_NOTIFY_LOG" \ + FAKE_SERVICES_ACTIVE=1 \ + "$DATA_HOME/bin/vigilia-arm" +} + +run_disarm() { + PATH="$FAKE_BIN:$PATH" \ + HOME="$HOME_ROOT" \ + XDG_CONFIG_HOME="$CONFIG_HOME" \ + XDG_DATA_HOME="$DATA_HOME" \ + XDG_STATE_HOME="$STATE_HOME" \ + XDG_RUNTIME_DIR="$RUNTIME_HOME" \ + FAKE_SYSTEMCTL_LOG="$FAKE_SYSTEMCTL_LOG" \ + "$DATA_HOME/bin/vigilia-disarm" +} + +assert_event() { + grep -F "$1" "$STATE_HOME/vigilia/events.tsv" >/dev/null +} + +wait_for_event() { + local expected="$1" + local deadline=$((SECONDS + 5)) + + until grep -F "$expected" "$STATE_HOME/vigilia/events.tsv" >/dev/null; do + if ((SECONDS >= deadline)); then + printf 'Timed out waiting for event: %s\n' "$expected" >&2 + return 1 + fi + sleep 0.05 + done +} + +: > "$STATE_HOME/vigilia/events.tsv" +write_env noctalia +FAKE_NOCTALIA_STATES='false,true,true,false' run_arm +assert_event $'ARMED\tservices=motion,power,inhibit backend=noctalia' +assert_event $'LOCK_ACQUIRED\tbackend=noctalia' +assert_event $'DISARMED\treason=unlock backend=noctalia' +grep -Fx 'lock-requested' "$FAKE_NOCTALIA_LOG" >/dev/null +grep -F $'text\tVigilia armed\tCamera monitoring, power monitoring, and sleep inhibition are active.\t3' \ + "$FAKE_NOTIFY_LOG" >/dev/null + +: > "$STATE_HOME/vigilia/events.tsv" +write_env noctalia swaylock 1 0.05 +force_unlock_file="$SCENARIO_HOME/force-unlock" +rm -f "$force_unlock_file" +FAKE_NOCTALIA_STATES='true' \ + FAKE_NOCTALIA_FORCE_UNLOCK_FILE="$force_unlock_file" run_arm & +arm_pid=$! +wait_for_event $'LOCK_ACQUIRED\tbackend=noctalia' +run_disarm +sleep 0.2 +if kill -0 "$arm_pid" 2>/dev/null; then + touch "$force_unlock_file" +fi +set +e +wait "$arm_pid" +manual_status=$? +set -e +test "$manual_status" -eq 143 +assert_event $'DISARMED\treason=manual' +if grep -F $'DISARMED\treason=unlock backend=noctalia' "$STATE_HOME/vigilia/events.tsv"; then + printf 'Manual disarm must not later be recorded as an unlock\n' >&2 + exit 1 +fi +test "$(grep -c $'\tDISARMED\t' "$STATE_HOME/vigilia/events.tsv")" -eq 1 + +: > "$STATE_HOME/vigilia/events.tsv" +sleep 30 & +unrelated_pid=$! +printf '%s\n' "$unrelated_pid" > "$RUNTIME_HOME/vigilia-arm.pid" +printf '0\n' > "$RUNTIME_HOME/vigilia-arm.start-time" +run_disarm +if ! kill -0 "$unrelated_pid" 2>/dev/null; then + printf 'A stale arm PID file must not terminate an unrelated process\n' >&2 + exit 1 +fi +kill "$unrelated_pid" +wait "$unrelated_pid" 2>/dev/null || true +assert_event $'DISARMED\treason=manual' + +: > "$STATE_HOME/vigilia/events.tsv" +write_env noctalia +set +e +FAKE_NOCTALIA_STATES='false,false,false,false' run_arm +timeout_status=$? +set -e +test "$timeout_status" -eq 1 +assert_event $'LOCK_FAILED\tbackend=noctalia reason=acquire_timeout' +if grep -F $'DISARMED\treason=unlock backend=noctalia' "$STATE_HOME/vigilia/events.tsv"; then + printf 'Noctalia acquire timeout must not be recorded as unlock\n' >&2 + exit 1 +fi + +grep -F 'stop vigilia.target' "$FAKE_SYSTEMCTL_LOG" >/dev/null + +: > "$STATE_HOME/vigilia/events.tsv" +write_env noctalia +set +e +FAKE_NOCTALIA_REQUEST_FAIL=1 FAKE_NOCTALIA_STATES='false' run_arm +request_status=$? +set -e +test "$request_status" -eq 1 +assert_event $'LOCK_FAILED\tbackend=noctalia reason=lock_request_failed' + +: > "$STATE_HOME/vigilia/events.tsv" +write_env noctalia +set +e +FAKE_NOCTALIA_STATES='true' FAKE_NOCTALIA_FAIL_STATUS_AFTER=1 run_arm +lost_status=$? +set -e +test "$lost_status" -eq 1 +assert_event $'LOCK_ACQUIRED\tbackend=noctalia' +assert_event $'LOCK_FAILED\tbackend=noctalia reason=status_unavailable_after_acquire' +if grep -F $'DISARMED\treason=unlock backend=noctalia' "$STATE_HOME/vigilia/events.tsv"; then + printf 'Noctalia status loss after acquisition must not be recorded as unlock\n' >&2 + exit 1 +fi + +: > "$STATE_HOME/vigilia/events.tsv" +write_env noctalia +set +e +FAKE_NOCTALIA_FAIL_STATUS_AFTER=0 FAKE_NOCTALIA_STATES='false' run_arm +unavailable_status=$? +set -e +test "$unavailable_status" -eq 1 +assert_event $'LOCK_FAILED\tbackend=noctalia reason=status_unavailable' +if grep -F $'DISARMED\treason=unlock backend=noctalia' "$STATE_HOME/vigilia/events.tsv"; then + printf 'Noctalia status loss before acquisition must not be recorded as unlock\n' >&2 + exit 1 +fi + +: > "$STATE_HOME/vigilia/events.tsv" +write_env command swaylock 0 +FAKE_SWAYLOCK_SLEEP=0.1 run_arm +assert_event $'LOCK_ACQUIRED\tbackend=command' +assert_event $'DISARMED\treason=unlock backend=command' +grep -Fx 'swaylock-called' "$FAKE_SWAYLOCK_LOG" >/dev/null + +: > "$STATE_HOME/vigilia/events.tsv" +write_env unsupported +set +e +run_arm +status=$? +set -e +test "$status" -eq 1 +assert_event $'LOCK_FAILED\tbackend=unsupported reason=unsupported_backend' + +grep -Fx 'LOCK_BACKEND="noctalia"' "$ROOT/config/vigilia.env.example" >/dev/null +grep -F 'msg session lock' "$ROOT/bin/vigilia-arm" >/dev/null + +echo 'lock lifecycle: passed' diff --git a/scripts/test-motion-format.sh b/scripts/test-motion-format.sh index 06e4535..ccbc8aa 100755 --- a/scripts/test-motion-format.sh +++ b/scripts/test-motion-format.sh @@ -19,6 +19,7 @@ STATE_HOME="$TMPDIR_TEST/state" FAKE_BIN="$TMPDIR_TEST/bin" mkdir -p "$CONFIG_HOME/vigilia" "$DATA_HOME/bin" "$STATE_HOME/vigilia" "$FAKE_BIN" +ln -s /usr/bin/dirname "$FAKE_BIN/dirname" install -m 755 "$ROOT/bin/common.sh" "$DATA_HOME/bin/common.sh" install -m 755 "$ROOT/bin/motion-format.sh" "$DATA_HOME/bin/motion-format.sh" install -m 755 "$ROOT/bin/vigilia-status" "$DATA_HOME/bin/vigilia-status" @@ -70,6 +71,24 @@ printf 'inactive\n' EOF chmod 755 "$FAKE_BIN/systemctl" +cat > "$FAKE_BIN/loginctl" <<'EOF' +#!/usr/bin/env bash + +exit 0 +EOF +chmod 755 "$FAKE_BIN/loginctl" + +cat > "$FAKE_BIN/noctalia" <<'EOF' +#!/usr/bin/env bash + +exit 0 +EOF +chmod 755 "$FAKE_BIN/noctalia" + +for command in awk curl systemd-inhibit flock; do + ln -s "/usr/bin/$command" "$FAKE_BIN/$command" +done + cat > "$FAKE_BIN/motion" <<'EOF' #!/usr/bin/env bash @@ -147,4 +166,57 @@ grep -F "Existing Motion configuration preserved: $CONFIG_HOME/vigilia/motion.co grep -F 'Its requested format may differ from the current template; inspect CAMERA_FORMAT_* events after arming.' \ <<< "$INSTALL_OUTPUT" >/dev/null +rm -rf "$CONFIG_HOME" "$DATA_HOME" +mv "$FAKE_BIN/noctalia" "$FAKE_BIN/noctalia.disabled" +set +e +INSTALL_OUTPUT="$(HOME="$TMPDIR_TEST/home" XDG_CONFIG_HOME="$CONFIG_HOME" \ + XDG_DATA_HOME="$DATA_HOME" PATH="$FAKE_BIN" \ + /usr/bin/bash "$ROOT/install.sh" 2>&1)" +MISSING_NOCTALIA_STATUS=$? +set -e +test "$MISSING_NOCTALIA_STATUS" -eq 1 +grep -Fx 'Vigilia installation is incomplete: missing commands: noctalia' \ + <<< "$INSTALL_OUTPUT" >/dev/null +test ! -e "$CONFIG_HOME/vigilia/vigilia.env" +test ! -e "$DATA_HOME/vigilia/bin/vigilia-arm" + +rm -f "$FAKE_BIN/awk" +set +e +INSTALL_OUTPUT="$(HOME="$TMPDIR_TEST/home" XDG_CONFIG_HOME="$CONFIG_HOME" \ + XDG_DATA_HOME="$DATA_HOME" PATH="$FAKE_BIN" \ + /usr/bin/bash "$ROOT/install.sh" 2>&1)" +MISSING_AWK_STATUS=$? +set -e +test "$MISSING_AWK_STATUS" -eq 1 +grep -Fx 'Vigilia installation is incomplete: missing commands: awk noctalia' \ + <<< "$INSTALL_OUTPUT" >/dev/null +test ! -e "$DATA_HOME/vigilia/bin/vigilia-arm" + +mkdir -p "$CONFIG_HOME/vigilia" +cat > "$CONFIG_HOME/vigilia/vigilia.env" <<'EOF' +LOCK_COMMAND="swaylock" +EOF +ln -s /usr/bin/awk "$FAKE_BIN/awk" +HOME="$TMPDIR_TEST/home" XDG_CONFIG_HOME="$CONFIG_HOME" \ + XDG_DATA_HOME="$DATA_HOME" PATH="$FAKE_BIN:/usr/bin:/bin" \ + /usr/bin/bash "$ROOT/install.sh" >/dev/null +test -x "$TMPDIR_TEST/home/.local/bin/vigilia-arm" + +rm -rf "$CONFIG_HOME" "$DATA_HOME" +mkdir -p "$CONFIG_HOME/vigilia" +cat > "$CONFIG_HOME/vigilia/vigilia.env" <<'EOF' +LOCK_BACKEND="noctalia" +EOF +mv "$FAKE_BIN/noctalia.disabled" "$TMPDIR_TEST/noctalia-disabled" +set +e +INSTALL_OUTPUT="$(HOME="$TMPDIR_TEST/home" XDG_CONFIG_HOME="$CONFIG_HOME" \ + XDG_DATA_HOME="$DATA_HOME" PATH="$FAKE_BIN" \ + /usr/bin/bash "$ROOT/install.sh" 2>&1)" +EXPLICIT_NOCTALIA_STATUS=$? +set -e +test "$EXPLICIT_NOCTALIA_STATUS" -eq 1 +grep -Fx 'Vigilia installation is incomplete: missing commands: noctalia' \ + <<< "$INSTALL_OUTPUT" >/dev/null +test ! -e "$DATA_HOME/vigilia/bin/vigilia-arm" + printf 'motion format probe: passed\n' diff --git a/scripts/verify.sh b/scripts/verify.sh index ab0d158..43ea33c 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -46,6 +46,7 @@ done printf 'bash syntax: ok (%d files)\n' "${#shell_files[@]}" ./scripts/test-motion-format.sh +./scripts/test-lock-lifecycle.sh if command -v shellcheck >/dev/null 2>&1; then shellcheck -x "${shell_files[@]}"