#!/bin/sh
# Download system updates now; install them on the next reboot.
#
# Windows and PureOS both update this way: the download happens in the
# background, and the installation happens early in the next boot, before
# anyone has logged in or has a document open -- so nothing is replaced under a
# running program, and a machine is never half-upgraded while in use.
#
# This is PackageKit's offline-update mechanism, not a new one. `pkcon update
# --only-download` fetches and prepares the transaction; `pkcon
# offline-trigger` marks it for the next boot, where systemd enters
# system-update.target and PackageKit's packagekit-offline-update.service
# installs it and reboots into the updated system.
#
# Run by sg-update-prepare.timer. Safe to run by hand; running it with nothing
# to update does nothing.
set -eu

log() { printf '[sg-update] %s\n' "$*"; }

# What is being downloaded, for Settings > Updates (David: "it should show the
# download progress of each update"). apt keeps a download in progress in
# partial/, which only root may look into, so this publishes it:
#   $STATE/plan       one line per file to fetch: FILE SIZE
#   $STATE/progress   the same with the bytes already here: FILE SIZE DONE
#   $STATE/state      downloading | ready | idle
STATE=${SG_UPDATE_STATE:-/run/sg-update}
ARCHIVES=${SG_APT_ARCHIVES:-/var/cache/apt/archives}

publish() {     # $1: file, then its content on stdin; readers never see half of it
    _t=$(mktemp "$STATE/.$1.XXXXXX") && cat > "$_t" && chmod 644 "$_t" && mv -f "$_t" "$STATE/$1"
}

write_progress() {
    [ -r "$STATE/plan" ] || return 0
    while read -r _f _size; do
        _done=0
        if [ -f "$ARCHIVES/$_f" ]; then _done=$(stat -c %s "$ARCHIVES/$_f")
        elif [ -f "$ARCHIVES/partial/$_f" ]; then _done=$(stat -c %s "$ARCHIVES/partial/$_f")
        fi
        printf '%s %s %s\n' "$_f" "$_size" "$_done"
    done < "$STATE/plan" | publish progress
}

# apt lists only what is not downloaded yet; asked with an empty archive it
# lists everything the upgrade needs, so a rerun still shows the whole plan
make_plan() {
    _empty=$(mktemp -d)
    mkdir -p "$_empty/partial"
    apt-get --print-uris -qq -o Debug::NoLocking=1 -o Dir::Cache::Archives="$_empty/" dist-upgrade 2>/dev/null |
        awk 'NF >= 3 && $3 ~ /^[0-9]+$/ {print $2, $3}' | publish plan
    rm -rf "$_empty"
}

mkdir -p "$STATE"
chmod 755 "$STATE"
WATCH=
finish() {
    if [ -n "$WATCH" ]; then kill "$WATCH" 2>/dev/null; wait "$WATCH" 2>/dev/null || true; fi
    write_progress
    if [ -e "${SG_SYSTEM_UPDATE:-/system-update}" ]; then echo ready; else echo idle; fi | publish state
}
trap finish EXIT

command -v pkcon >/dev/null 2>&1 || { log "PackageKit (pkcon) is not installed"; exit 0; }

# pkcon's exit codes: 0 success, 5 "nothing useful was done" (for example,
# nothing to update). Anything else is a failure worth a non-zero exit, so the
# timer's failures show up in `systemctl --failed`.
pk() {
    _rc=0
    pkcon --noninteractive "$@" || _rc=$?
    case $_rc in
        0|5) return 0 ;;
        *)   log "pkcon $* failed ($_rc)"; return "$_rc" ;;
    esac
}

# An installation cut short -- a power cut, an update ended from Task Manager
# (David's laptop 2026-10-03: sg-session -95's own hang) -- leaves dpkg
# "interrupted", and PackageKit then refuses every update until someone runs
# dpkg --configure -a: the updates sat "pending" for good. Finish it here.
# The packages left half-done are first replaced by their newest versions:
# what stopped them may be the very fault a newer one fixes, and their
# superseded versions are not worth finishing.
unfinished() {  # packages unpacked, half-configured, half-installed or awaiting triggers
    dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk 'substr($1, 2, 1) ~ /[UFHWt]/ {print $2}'
}
repair_dpkg() {
    _pkgs=$(unfinished)
    [ -n "$_pkgs" ] || return 0
    log "an earlier installation was cut short: $(printf '%s' "$_pkgs" | tr '\n' ' ')"
    apt-get update -qq -o DPkg::Lock::Timeout=120 >/dev/null 2>&1 || log "apt-get update failed; finishing with what is here"
    _dl=$(mktemp -d)
    # shellcheck disable=SC2086  # one word per package
    (cd "$_dl" && apt-get download -qq $_pkgs >/dev/null 2>&1) || true
    _newer=""
    for _deb in "$_dl"/*.deb; do
        [ -f "$_deb" ] || continue
        _p=$(dpkg-deb -f "$_deb" Package) _v=$(dpkg-deb -f "$_deb" Version)
        _have=$(dpkg-query -W -f '${Version}' "$_p" 2>/dev/null)
        if dpkg --compare-versions "$_v" gt "$_have"; then
            _newer="$_newer $_deb"; log "$_p $_have is superseded by $_v"
        fi
    done
    # shellcheck disable=SC2086
    [ -z "$_newer" ] || DEBIAN_FRONTEND=noninteractive timeout 1800 dpkg --force-confdef --force-confold -i $_newer ||
        log "installing the newer versions failed"
    DEBIAN_FRONTEND=noninteractive timeout 1800 dpkg --force-confdef --force-confold --configure -a ||
        log "dpkg --configure -a failed"
    rm -rf "$_dl"
    # A package whose unpacking never happened (the disk was full: qa5
    # 2026-10-21, libserialport0) leaves the ones that depend on it unpacked
    # for good: dpkg cannot configure them, and only apt fetches what they
    # lack ("Unmet dependencies. Try 'apt --fix-broken install'").
    if [ -n "$(unfinished)" ] && [ "${SG_MUTANT:-}" != NO_FIXBROKEN ]; then
        log "fetching what the unfinished packages lack (apt-get -f install)"
        DEBIAN_FRONTEND=noninteractive timeout 1800 apt-get -y -q -o DPkg::Lock::Timeout=600 \
            -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -f install >/dev/null 2>&1 ||
            log "apt-get -f install failed"
    fi
    _left=$(unfinished)
    if [ -z "$_left" ]; then log "the cut-short installation is finished"
    else log "still unfinished: $(printf '%s' "$_left" | tr '\n' ' ')"; fi
}
repair_dpkg

# A third-party repository configured twice with different keyrings (SG
# Store's mozilla.list and Mozilla's own instructions' entry, a VM of
# David's 2026-10-06) makes apt refuse ALL its sources: "Conflicting values
# set for option Signed-By", then no update from anywhere. sg-apt-sources
# heals that class first (one keyring per repository, the repeat turned off,
# the files backed up), logging each change here.
APTSRC=${SG_APT_SOURCES_TOOL:-/usr/lib/stained-glass/sg-apt-sources}
if [ -x "$APTSRC" ]; then
    _h=$("$APTSRC" heal 2>&1) || log "the package sources could not all be repaired"
    [ -z "$_h" ] || printf '%s\n' "$_h" | sed 's/^/[sg-update] /'
fi

log "refreshing package lists"
# One archive out of reach (a Surface PC's linux-surface archive, say), or
# one whose signing key expired or was replaced, must not hold back every
# other update: its old list stays, the rest refresh -- and Settings > Updates
# says which one and why ($STATE/problems: HOST<TAB>reason).
_upd=$(apt-get update -q -o DPkg::Lock::Timeout=120 2>&1) || log "not every package list could be refreshed; going on with the lists here"
if [ -x "$APTSRC" ]; then
    printf '%s\n' "$_upd" | "$APTSRC" problems 2>/dev/null | publish problems || true
    [ ! -s "$STATE/problems" ] || sed 's/^/[sg-update] cannot use /; s/\t/: /' "$STATE/problems"
fi
pk refresh || log "PackageKit could not refresh every package list; going on with the lists here"

make_plan
echo downloading | publish state
( while :; do write_progress; sleep 1; done ) &
WATCH=$!

log "downloading available updates"
out=$(pkcon --noninteractive update --only-download 2>&1) && rc=0 || rc=$?
printf '%s\n' "$out"
case $rc in
    5) log "no updates available"; exit 0 ;;
    0) ;;
    *) log "download failed ($rc)"; exit "$rc" ;;
esac

# Something was downloaded and prepared: install it on the next reboot.
pk offline-trigger
log "updates downloaded; they will be installed on the next reboot"
