#!/usr/bin/env bash
# charter-setup — provision account lockdown for the managed child.
#
# Run as root. With NO argument on a desktop it shows a graphical wizard (pick
# the child's account); `charter-setup <username>` is the headless form. Both
# are idempotent (safe to re-run). Guardian pairing is optional — device-only
# screen-time limits work without it (set them in the Kintrinsic app (The rules)).
#
# App lock (opt-in, never automatic): `charter-setup --arm-app-lock <username>`
# also arms fapolicyd so the ward can only run installed programs — see
# charter-applock(8) for the self-test it runs first. `charter-setup
# --disarm-app-lock` (no username) undoes it; so does `charter-applock disarm`.
set -euo pipefail

GUI=0
MANAGED_USER=""
ARM_APP_LOCK=0
DISARM_APP_LOCK=0
BAD_ARG=""
for arg in "$@"; do
    case "$arg" in
        --arm-app-lock) ARM_APP_LOCK=1 ;;
        --disarm-app-lock) DISARM_APP_LOCK=1 ;;
        -*) BAD_ARG="$arg" ;;
        *) MANAGED_USER="$arg" ;;
    esac
done

# Show a graphical error in GUI mode, always log to stderr.
fail() {
    [ "$GUI" = 1 ] && zenity --error --title="Kintrinsic Setup" --text="$1" 2>/dev/null || true
    echo "charter-setup: $1" >&2
    exit 1
}

# --- sudoers FILE-syntax scan -----------------------------------------------
#
# `sudo -n -l -U` (below) tells us what sudo itself thinks the account can
# run, but it depends on sudo's own PAM/NSS lookup working for that account.
# Belt-and-braces: read /etc/sudoers + /etc/sudoers.d/* directly too.
#
# This used to reuse `grant_re`, which is written for `sudo -l` OUTPUT —
# `(ALL : ALL) ALL`, i.e. the "(runas) commands" tail with nothing in front
# of it. A real sudoers FILE line is `user ALL=(ALL:ALL) ALL` or
# `%group ALL=(ALL) NOPASSWD: ALL` (may be indented or tab-separated, and the
# host list before `=` need not be `ALL`) — `grant_re` requires a literal `(`
# immediately after the leading whitespace, which a file line never has (it
# has `user`/`%group` + a host list + `=` first), so the file scan never
# matched anything. It ran, found nothing, and reported "clean" regardless of
# what was actually in the file.
#
# who_re: the exact "user" or "%group" token as it appears in a sudoers
# entry (already escaped for regex use).
# grant_tail: what comes after `=` — an optional `(runas)`, then any number
# of tags (`NOPASSWD:`, `SETENV:`, …), then a command list. Only a command
# list that IS (or contains, comma-separated) the blanket `ALL` counts —
# matches the same "blanket only" policy as the sudo -l check below.
sudoers_grant_tail_re='(\([^)]*\))?[[:space:]]*([A-Za-z_]+:[[:space:]]*)*ALL[[:space:]]*(,.*)?$'

# sudoers_line_is_blanket_grant <who> <line>
# <who> is an exact "user" or "%group" token. Returns 0 (match) if <line> is
# an uncommented sudoers-file entry granting <who> a blanket ALL command
# list.
sudoers_line_is_blanket_grant() {
    local who="$1" line="$2" who_re
    who_re="$(printf '%s' "$who" | sed 's/[.[\*^$]/\\&/g')"
    printf '%s\n' "$line" | grep -vE '^[[:space:]]*#' \
        | grep -qE "^[[:space:]]*${who_re}[[:space:]]+[^=]+=[[:space:]]*${sudoers_grant_tail_re}"
}

# _sudoers_collect_files <sudoers-path> <out-array-name>
# Collects <sudoers-path> itself, every file in the sibling `sudoers.d`
# directory next to it (the conventional layout — `/etc/sudoers` +
# `/etc/sudoers.d`, and what a test harness gets by pointing <sudoers-path>
# at a fixture dir), and every file in any directory named by an
# `@includedir`/`#includedir` line inside <sudoers-path> — skipping names
# sudo itself ignores: anything containing `.` or ending in `~` (editor
# backups, .rpmnew, .dpkg-*, …).
_sudoers_collect_files() {
    local root="$1"
    local -n _sudoers_out="$2"
    _sudoers_out=()
    [ -f "$root" ] && _sudoers_out+=("$root")

    local dir f base
    dir="$(dirname "$root")/sudoers.d"
    if [ -d "$dir" ]; then
        for f in "$dir"/*; do
            [ -f "$f" ] || continue
            base="$(basename "$f")"
            case "$base" in
                *.*|*~) continue ;;
            esac
            _sudoers_out+=("$f")
        done
    fi

    [ -f "$root" ] || return 0
    local inc idir
    while IFS= read -r inc; do
        idir="${inc#*includedir}"
        idir="$(printf '%s' "$idir" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
        [ -n "$idir" ] && [ -d "$idir" ] || continue
        for f in "$idir"/*; do
            [ -f "$f" ] || continue
            base="$(basename "$f")"
            case "$base" in
                *.*|*~) continue ;;
            esac
            _sudoers_out+=("$f")
        done
    done < <(grep -E '^[[:space:]]*[@#]includedir[[:space:]]+' "$root" 2>/dev/null || true)
}

# sudoers_scan_blanket <user> [group ...]
# Scans /etc/sudoers, /etc/sudoers.d/*, and any @includedir targets for a
# blanket ALL grant to <user> (by name) or any of the given groups (by
# `%group`). Prints the matching file and returns 0 on the first hit;
# returns 1 if none found. Independent of `command -v sudo` — this reads
# the files directly.
sudoers_scan_blanket() {
    local user="$1"; shift
    local -a who_list=("$user") files=()
    local g f line who
    for g in "$@"; do who_list+=("%$g"); done
    _sudoers_collect_files /etc/sudoers files
    for f in "${files[@]}"; do
        while IFS= read -r line || [ -n "$line" ]; do
            for who in "${who_list[@]}"; do
                if sudoers_line_is_blanket_grant "$who" "$line"; then
                    printf '%s\n' "$f"
                    return 0
                fi
            done
        done < "$f"
    done
    return 1
}

# current_session_uid: best-effort uid of the account that owns the session
# THIS SCRIPT is running in — i.e. the parent at the desktop clicking through
# the wizard, or the admin who typed `sudo charter-setup`. Tried in order:
# pkexec's own record of who invoked it, sudo's, then (no wrapper at all —
# e.g. run directly inside a root shell in a graphical session) logind's
# session table, matched by walking this process's ancestry for a PID that
# some session claims as its Leader. Prints nothing and fails if none of
# these resolve (unknown, not "no session").
current_session_uid() {
    if [ -n "${PKEXEC_UID:-}" ]; then
        printf '%s\n' "$PKEXEC_UID"
        return 0
    fi
    if [ -n "${SUDO_UID:-}" ]; then
        printf '%s\n' "$SUDO_UID"
        return 0
    fi
    command -v loginctl >/dev/null 2>&1 || return 1
    local ancestors=" " p="$$" ppid
    while [ -n "$p" ] && [ "$p" -gt 1 ] 2>/dev/null; do
        ancestors="$ancestors$p "
        ppid="$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null || true)"
        [ -z "$ppid" ] || [ "$ppid" = "$p" ] && break
        p="$ppid"
    done
    local sid leader uid
    while IFS= read -r sid; do
        [ -n "$sid" ] || continue
        leader="$(loginctl show-session "$sid" -p Leader --value 2>/dev/null || true)"
        case "$ancestors" in
            *" $leader "*)
                uid="$(loginctl show-session "$sid" -p User --value 2>/dev/null || true)"
                if [ -n "$uid" ]; then
                    printf '%s\n' "$uid"
                    return 0
                fi
                ;;
        esac
    done < <(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}')
    return 1
}

# Let a test harness `source` this script to call the functions above
# without running the rest of setup.
[[ "${BASH_SOURCE[0]}" == "$0" ]] || return 0

if [ -n "$BAD_ARG" ]; then
    echo "usage: charter-setup [--arm-app-lock] <managed-username> | --disarm-app-lock" >&2
    exit 2
fi

# The app lock's single undo, needing no account name.
if [ "$DISARM_APP_LOCK" = 1 ]; then
    exec /usr/sbin/charter-applock disarm
fi

# No user named + a desktop available -> graphical wizard.
if [ -z "$MANAGED_USER" ] && command -v zenity >/dev/null 2>&1 && [ -n "${DISPLAY:-}" ]; then
    GUI=1
    trap 'fail "setup did not finish — see the system log (journalctl -u charterd)."' ERR
    HUMANS="$(getent passwd | awk -F: '$3>=1000 && $3<65000 && $7 !~ /(nologin|false|sync)$/ {print $1}')"
    [ -z "$HUMANS" ] && fail "No ordinary user accounts were found to manage."
    MANAGED_USER="$(printf '%s\n' "$HUMANS" | zenity --list \
        --title="Kintrinsic Setup" --text="Which account does your child use?" \
        --column="Account" 2>/dev/null)" || exit 0
    [ -z "$MANAGED_USER" ] && exit 0
    zenity --question --title="Kintrinsic Setup" --ok-label="Set up" --cancel-label="Cancel" \
        --text="Set up Kintrinsic for \"$MANAGED_USER\"?\n\nThis removes that account from the admin group and starts enforcing screen-time limits. You'll set the actual limits next, in the Kintrinsic app (\"The rules\")." \
        2>/dev/null || exit 0
fi

if [ -z "$MANAGED_USER" ]; then
    echo "usage: charter-setup [--arm-app-lock] <managed-username> | --disarm-app-lock" >&2
    exit 2
fi
id "$MANAGED_USER" >/dev/null 2>&1 || fail "user '$MANAGED_USER' does not exist"

# Brick guard: never lock down the machine's ONLY administrator. charter-setup
# strips the chosen account out of the admin groups, so doing that to the sole
# admin would leave nobody able to run Recovery or undo Kintrinsic (Recovery itself
# requires an admin). The graphical picker happily lists the parent's own
# account, so catch the mistake here — for both the GUI and headless paths.
target_is_admin=0
if id -nG "$MANAGED_USER" 2>/dev/null | tr ' ' '\n' | grep -qxE 'sudo|wheel|admin'; then
    target_is_admin=1
fi
other_admins="$(getent group sudo wheel admin 2>/dev/null \
    | awk -F: '{print $4}' | tr ',' '\n' | sort -u \
    | grep -vx "$MANAGED_USER" | grep -cv '^$' || true)"
if [ "$target_is_admin" = 1 ] && [ "${other_admins:-0}" -eq 0 ]; then
    fail "\"$MANAGED_USER\" is this computer's only administrator. Create a separate admin account for yourself first, then set up Kintrinsic for the child — otherwise stripping their admin rights would lock everyone out (Recovery needs an admin account)."
fi

echo "==> account lockdown for '$MANAGED_USER'"
# Dedicated group that the polkit rules key off.
getent group charter-managed >/dev/null || groupadd --system charter-managed
usermod -aG charter-managed "$MANAGED_USER"
# Out of the admin groups — the broker becomes the only privileged path. The
# list covers every group the brick guard above counts as admin (sudo, wheel,
# the legacy Ubuntu `admin`) plus adm/lpadmin; stripping fewer than the guard
# tests left a wheel/admin child with full sudo under a "lockdown" that
# reported success.
for g in sudo wheel admin adm lpadmin; do
    gpasswd -d "$MANAGED_USER" "$g" 2>/dev/null || true
done
# Verify, don't trust: gpasswd can't remove a PRIMARY group, and a personal
# sudoers entry isn't a group at all. Every other control is moot while the
# child can still become root, so refuse to report a lockdown that isn't one.
still_admin="$(id -nG "$MANAGED_USER" 2>/dev/null | tr ' ' '\n' \
    | grep -xE 'sudo|wheel|admin' | tr '\n' ' ' || true)"
if [ -n "$still_admin" ]; then
    fail "\"$MANAGED_USER\" is still in an administrator group (${still_admin% }) and could not be removed from it — Kintrinsic cannot protect this computer while that account can become root. Remove the account from that group, then run setup again."
fi
# Only a BLANKET rule ("(ALL : ALL) ALL", "(root) NOPASSWD: ALL") counts: Mint
# grants every account a few fixed helper commands (mint-refresh-cache, …), so
# "may run something" is true of every user on the target platform.
grant_re='\([^)]*\)[[:space:]]+(.*[,:][[:space:]]*)?ALL[[:space:]]*(,.*)?$'
if command -v sudo >/dev/null 2>&1 \
    && LC_ALL=C sudo -n -l -U "$MANAGED_USER" 2>/dev/null \
        | grep -qE "^[[:space:]]+${grant_re}"; then
    fail "\"$MANAGED_USER\" still has sudo rights from a sudoers rule (see /etc/sudoers and /etc/sudoers.d) — Kintrinsic cannot protect this computer while that account can become root. Remove the rule, then run setup again."
fi
# Belt-and-braces (04-G3): `sudo -n -l -U` depends on sudo's own PAM/NSS
# lookup working for this account, and it's the only check above that looks
# at sudoers at all — read /etc/sudoers + /etc/sudoers.d/* directly too, by
# the account's own name AND by every group it remains in (a `%group` grant
# is invisible to a by-name check). Uses `sudoers_scan_blanket`, which parses
# actual sudoers FILE syntax (see the comment above its definition) — this
# runs regardless of whether sudo itself is even installed.
sudoers_hit_file="$(sudoers_scan_blanket "$MANAGED_USER" $(id -nG "$MANAGED_USER" 2>/dev/null) || true)"
if [ -n "$sudoers_hit_file" ]; then
    fail "\"$MANAGED_USER\" still has a blanket sudo grant in $sudoers_hit_file — Kintrinsic cannot protect this computer while that account can become root. Remove the rule, then run setup again."
fi
# Root-equivalent groups beyond the admin set (04-G3): docker/lxd hand a
# member root via a mountable container, disk gives raw block-device access,
# shadow gives read access to the password hashes. None of these trip the
# brick guard's admin test, but any one of them is still a route to root.
# Strip them too (best-effort) and warn — not fail — if one survives, since
# unlike sudo/wheel/admin a family box may have a legitimate reason to keep
# the child in one of these (e.g. `disk` from an old udev rule).
root_equiv_warn=""
for g in docker lxd disk shadow libvirt kvm; do
    if id -nG "$MANAGED_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$g"; then
        gpasswd -d "$MANAGED_USER" "$g" 2>/dev/null || true
        if id -nG "$MANAGED_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$g"; then
            root_equiv_warn="$root_equiv_warn $g"
        fi
    fi
done
if [ -n "$root_equiv_warn" ]; then
    echo "charter-setup: WARN \"$MANAGED_USER\" is still in root-equivalent group(s):${root_equiv_warn} — this can be used to gain root despite the lockdown. Remove manually if not needed."
fi

echo "==> charter state dirs"
install -d -m 0755 /var/lib/charter
install -d -m 0700 /var/lib/charter/approved

echo "==> host config (/etc/charter/charterd.env)"
# The daemon resolves the active display + X authority LIVE (kernel active VT
# -> the Xorg serving it) — do NOT pin CHARTER_DISPLAY/CHARTER_XAUTHORITY here:
# a hardcoded :0 targets the wrong X under user switching (multiple X servers),
# drawing the lock into another session's void. They remain available as
# manual overrides for exotic hosts. Re-derived every run (idempotent).
install -d -m 0755 /etc/charter
MANAGED_UID="$(id -u "$MANAGED_USER")"
ENV_FILE=/etc/charter/charterd.env
# Merge, don't truncate. A from-scratch `cat >` rewrite destroyed whatever
# CHARTER_DISPLAY / CHARTER_XAUTHORITY / CHARTER_LOCK_BIN overrides the env
# example tells admins to hand-edit, and silently dropped CHARTER_ENFORCE
# back to full enforce on any re-run that didn't happen to export it — the
# console's "Set up" button re-runs this on every press (04-B9). Copy every
# existing line except the two keys this run may (re)write, keep the old
# CHARTER_ENFORCE line unless THIS run explicitly passes one, then append
# the fresh values.
ENV_TMP="$(mktemp /etc/charter/charterd.env.XXXXXX)"
# Belt-and-braces: if anything below fails (or the wizard's own ERR trap
# fires) before the mv, don't leave a stray, possibly-secret-bearing temp
# file sitting in /etc/charter. A successful `mv "$ENV_TMP" "$ENV_FILE"`
# renames the file out from under this trap, so it's a harmless no-op then.
trap 'rm -f "$ENV_TMP"' EXIT
if [ -f "$ENV_FILE" ]; then
    grep -vE '^(CHARTER_MANAGED_UID=|CHARTER_ENFORCE=)' "$ENV_FILE" > "$ENV_TMP" 2>/dev/null || true
    if [ -z "${CHARTER_ENFORCE:-}" ]; then
        grep -E '^CHARTER_ENFORCE=' "$ENV_FILE" >> "$ENV_TMP" 2>/dev/null || true
    fi
fi
echo "CHARTER_MANAGED_UID=${MANAGED_UID}" >> "$ENV_TMP"
# Optional staged bring-up mode (observe / freeze-only); absent => full enforce
# (or whatever was already on disk, preserved above).
if [ -n "${CHARTER_ENFORCE:-}" ]; then
    echo "CHARTER_ENFORCE=${CHARTER_ENFORCE}" >> "$ENV_TMP"
    echo "charter-setup: enforce mode = ${CHARTER_ENFORCE} (written to charterd.env)"
fi
chmod 0644 "$ENV_TMP"
mv "$ENV_TMP" "$ENV_FILE"

echo "==> default screen-time limits for '$MANAGED_USER' (per-child)"
# Seed sensible defaults so the child is enforced immediately; the parent tunes
# them in the Kintrinsic app (The rules). Each child has their own limits.d/<user>.json,
# so multiple children get independent hours/curfews. Don't clobber an existing
# file (a re-run keeps the parent's chosen limits).
install -d -m 0755 /etc/charter/limits.d
LIMITS_FILE="/etc/charter/limits.d/${MANAGED_USER}.json"
if [ ! -f "$LIMITS_FILE" ]; then
    # Resolve the host's real timezone rather than defaulting to UTC: a wrong
    # tz silently shifts wake/bedtime by hours with no error anywhere
    # (04-B10). Only needed here — a re-run with an existing limits file
    # never touches tz, so a host with no resolvable timezone should not
    # start failing an otherwise-idempotent re-run.
    # 1) systemd's own idea of it (works everywhere systemd runs, not just
    #    Debian).
    # 2) the /etc/localtime symlink target, which is how the TZ actually
    #    takes effect regardless of what any text file says.
    # 3) /etc/timezone (Debian/Ubuntu's own record) as a last resort.
    # If none of those resolve, fail loudly instead of silently mislocking
    # the child's day on UTC.
    #
    # Each candidate is VALIDATED, not trusted: `timedatectl` prints the
    # literal string "n/a" when it doesn't know the timezone (that is not a
    # zone name — treat it as unresolved and fall through to the next
    # source), and only a name that both matches the IANA zone-name shape
    # and has a real file under /usr/share/zoneinfo is accepted. An
    # unvalidated name reaching the limits file is exactly the "silently
    # mislocking on garbage" failure this whole check exists to prevent.
    tz_is_valid() {
        [[ "$1" =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$ ]] && [ -e "/usr/share/zoneinfo/$1" ]
    }
    TZ_NAME="$(timedatectl show -p Timezone --value 2>/dev/null || true)"
    tz_is_valid "$TZ_NAME" || TZ_NAME=""
    if [ -z "$TZ_NAME" ] && [ -L /etc/localtime ]; then
        TZ_NAME="$(readlink -f /etc/localtime 2>/dev/null | sed -n 's#.*/zoneinfo/##p')"
        tz_is_valid "$TZ_NAME" || TZ_NAME=""
    fi
    if [ -z "$TZ_NAME" ]; then
        TZ_NAME="$(tr -d '[:space:]' < /etc/timezone 2>/dev/null || true)"
        tz_is_valid "$TZ_NAME" || TZ_NAME=""
    fi
    [ -z "$TZ_NAME" ] && fail "couldn't determine this computer's timezone (checked timedatectl, /etc/localtime, /etc/timezone). Set one (e.g. 'sudo timedatectl set-timezone Region/City') and run setup again — Kintrinsic refuses to guess UTC and silently shift the child's wake/bedtime hours."
    cat > "$LIMITS_FILE" <<EOF
{
  "tz": "${TZ_NAME}",
  "wake": "07:00",
  "bedtime": "20:00",
  "dailyMinutes": 120
}
EOF
    chmod 0644 "$LIMITS_FILE"
fi

echo "==> enabling units"
systemctl daemon-reload
systemctl enable --now charterd.service
# noexec tmpfs mounts (the fstab mounts for $HOME/media are applied per-host —
# see /usr/share/charter/mounts/noexec.fstab.example).
if [ "${CHARTER_SKIP_NOEXEC:-0}" = 1 ]; then
    echo "charter-setup: CHARTER_SKIP_NOEXEC=1 — leaving /tmp + /dev/shm as-is (no noexec)."
else
    # `enable`, never `--now`: starting these mid-session stacks an empty tmpfs
    # over the LIVE /tmp and /dev/shm, hiding /tmp/.X11-unix and every socket
    # and shared-memory segment the parent's running desktop is using — and
    # this wizard runs FROM that desktop. They take effect at the next boot.
    if systemctl enable tmp.mount dev-shm.mount; then
        NOEXEC_PENDING=1
    else
        echo "charter-setup: WARN could not enable noexec tmpfs mounts (tune on host)"
    fi
fi

# The app lock, only when asked for. It self-tests before enforcing and puts
# everything back itself if the self-test fails, so a failure here leaves the
# account lockdown above in place and the app lock simply off.
APPLOCK_FAILED=0
if [ "$ARM_APP_LOCK" = 1 ]; then
    echo "==> app lock (fapolicyd)"
    if ! /usr/sbin/charter-applock arm; then
        APPLOCK_FAILED=1
        echo "charter-setup: WARN the app lock was NOT armed (see above). Everything else is set up; fix the reason and run 'charter-applock arm'."
    fi
elif [ -f /etc/fapolicyd/rules.d/72-kintrinsic-applock.rules ] && [ -x /usr/sbin/charter-applock ]; then
    # Already armed for other wards: a new ward must not be the one account
    # the lock misses. `refresh` is a no-op for an account already covered.
    echo "==> app lock is armed: extending it to \"$MANAGED_USER\""
    if ! /usr/sbin/charter-applock refresh; then
        APPLOCK_FAILED=1
        echo "charter-setup: WARN the app lock does NOT cover \"$MANAGED_USER\" (see above); the guardian's app will show it as not armed. Fix the reason and run 'charter-applock refresh'."
    fi
fi

NOEXEC_NOTE=""
if [ "${NOEXEC_PENDING:-0}" = 1 ]; then
    NOEXEC_NOTE="\n\nRestart this computer when convenient — one protection only switches on at the next start."
    echo "==> noexec /tmp + /dev/shm enabled; they take effect at the next boot."
fi

# Group membership is read at LOGIN — polkit evaluates the session's groups,
# not the account's current ones, so if the child is logged in right now (the
# normal case: this wizard runs from the parent's desktop on a shared box)
# every lockdown above is inert in their existing session until they log out
# and back in (04-G3).
#
# This offer runs LAST, after state dirs / env / limits / `systemctl enable
# --now` are all written — `loginctl terminate-user` kills every process in
# the target session, and if the parent ran this wizard FROM the child's own
# session (e.g. sat the child down at their own desktop and typed the
# command there), ending that session mid-script would kill the script
# before any of the above ran. Doing the offer last means a self-terminate
# only ever cuts off the (already-printed) final messages, never the setup
# itself.
#
# And skip the offer entirely — don't even ask — when the session THIS
# SCRIPT is running in belongs to the child being locked down: ending your
# own session is not something to offer as a button, it's just "log out
# yourself when you're done here".
echo "charter-setup: log \"$MANAGED_USER\" out and back in for this to take effect."
session_uid="$(current_session_uid || true)"
if [ -n "$session_uid" ] && [ "$session_uid" = "$MANAGED_UID" ]; then
    echo "charter-setup: this wizard is running from \"$MANAGED_USER\"'s own session — log out and back in yourself once you're done here to pick it up."
elif [ "$GUI" = 1 ] && command -v loginctl >/dev/null 2>&1 \
    && loginctl show-user "$MANAGED_USER" >/dev/null 2>&1; then
    if zenity --question --title="Kintrinsic Setup" --ok-label="Log them out now" --cancel-label="Later" \
        --text="\"$MANAGED_USER\" is logged in right now. The lockdown above only applies to their NEXT login — until then their current session keeps the old permissions.\n\nEnd their session now so it applies immediately? (Any unsaved work in their session is lost.)" \
        2>/dev/null; then
        if loginctl terminate-user "$MANAGED_USER" 2>/dev/null; then
            echo "charter-setup: ended \"$MANAGED_USER\"'s session(s)."
        else
            echo "charter-setup: WARN could not end \"$MANAGED_USER\"'s session — log them out manually."
        fi
    fi
fi

if [ "$GUI" = 1 ]; then
    zenity --info --title="Kintrinsic Setup" \
        --text="Kintrinsic is set up for \"$MANAGED_USER\".\n\nNow open the Kintrinsic app (\"The rules\") to set the daily limits.${NOEXEC_NOTE}" \
        2>/dev/null || true
else
    echo "==> guardian pairing is optional (device-only limits work without it)."
    echo "==> done. Set limits in the Kintrinsic app (The rules), or pair a phone guardian from the Kintrinsic app -> Connect (needs an admin password)."
fi

# Setup itself succeeded; say so in the exit status only if the app lock that
# was explicitly asked for did not arm.
exit "$APPLOCK_FAILED"
