diff --git a/configs/general/general_basic b/configs/general/general_basic
index af6b046..47c8458 100755
--- a/configs/general/general_basic
+++ b/configs/general/general_basic
@@ -3,5 +3,5 @@
# @icon ๐ท๏ธ
# ================================================================================
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance
-CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps
+CFG_TIMEZONE=Etc/UTC # Container Timezone - Timezone handed to app containers (their TZ). Scheduled tasks โ backups, update checks โ follow the HOST's own clock, set with timedatectl, not this value.
CFG_INSTALL_LEVEL=beginner # Experience Level - Beginner hides technical detail and skips advanced setup steps. Advanced reveals everything by default. Set during the first-run wizard; can be flipped any time via the Advanced toggle in the WebUI. [beginner:Beginner โ simple|advanced:Advanced โ show everything]
diff --git a/configs/webui/webui_updater b/configs/webui/webui_updater
index 3e3bfaf..b3770eb 100644
--- a/configs/webui/webui_updater
+++ b/configs/webui/webui_updater
@@ -3,6 +3,7 @@
# @icon ๐
# ================================================================================
CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans. 0 disables.
-CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = never (local-only).
+CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = only when you press Check now.
CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off]
CFG_UPDATER_AUTO=true # Automatic App Updates - Master switch for per-app automatic updates. Each app's own Update Type decides individually; turning this off makes every app manual. Every update snapshots the app first and rolls back on failure. [true:On|false:Off]
+CFG_UPDATER_WINDOW=06:00-08:00 # Automatic Update Window - When automatic updates are allowed to install, in the host's local time (HH:MM-HH:MM; crosses midnight when start > end; 'always' = any time). Checks still run all day so the Updates page stays current โ found updates simply wait for the window. Pressing Update yourself always works.
diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
index badbf62..4d479e4 100644
--- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
+++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
@@ -407,9 +407,12 @@ class OverviewManager {
// wolf. They still get a quiet line so nothing is hidden.
// With automatic scanning off nothing is found unattended, so an "auto"
// app's update sits there until someone presses Check โ that IS waiting
- // for the user, whatever the policy says.
+ // for the user, whatever the policy says. Likewise a build the auto-
+ // updater already attempted and rolled back: it will not be retried, so
+ // it waits for a person too (updater-page's autoAttemptFailed).
const autoScan = !(up.updates && Number(up.updates.scan_interval_minutes) === 0);
- const waiting = pending.filter((a) => !autoScan || a.update_type === 'manual');
+ const waiting = pending.filter((a) =>
+ !autoScan || a.update_type === 'manual' || (up.autoAttemptFailed && up.autoAttemptFailed(a)));
const selfing = pending.length - waiting.length;
const listOf = (arr) => {
const names = arr.map((a) => a.displayName || a.name);
@@ -425,9 +428,14 @@ class OverviewManager {
+ ``,
});
} else {
+ // Say WHEN, not just that it happens โ "installing automatically"
+ // with no time reads as "any second now", which a 06:00-08:00 window
+ // makes untrue for most of the day.
+ const win = up.updates && up.updates.auto_window;
+ const winBit = (win && win !== 'always') ? ` during the ${esc(win).replace('-', 'โ')} window` : '';
rows.push({
hue: 'updates', icon: 'โฌ๏ธ', kind: 'ok',
- text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically`,
+ text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`,
sub: `${esc(listOf(pending))} โ each is snapshotted first, and rolled back if it fails`,
actions: goto('updates', 'Review', 'updates'),
});
@@ -583,9 +591,14 @@ class OverviewManager {
// Automatic is the default, so only the apps that opt OUT carry a marker โ
// 30-odd "auto" chips would be wallpaper, one "manual" chip is information.
// The fleet-level statement lives in the auto-check line above the list.
+ // A failed automatic attempt is the other marker that earns a chip: that
+ // update will NOT retry itself, so the row must say a person is needed.
+ const failed = this.updater && this.updater.autoAttemptFailed && this.updater.autoAttemptFailed(a);
const pol = a.update_type === 'manual'
? `manual`
- : '';
+ : (failed
+ ? `auto failed`
+ : '');
const updBtn = a.update_available
? ``
: '';
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js
index 9bb0b15..cc09f05 100644
--- a/containers/libreportal/frontend/components/updater/js/updater-page.js
+++ b/containers/libreportal/frontend/components/updater/js/updater-page.js
@@ -541,10 +541,15 @@ class UpdaterPage {
: (a.scanned ? `up to date` : `unscanned`);
// What happens next, in the app's own words โ the Updates setting on this
// app's Configure page decides, so say which way it is set rather than
- // leaving "update available" to imply someone must act.
+ // leaving "update available" to imply someone must act. One honest
+ // exception: an auto app whose available build was already attempted and
+ // rolled back will NOT retry (by design โ one shot per build), so saying
+ // "installs on its own" there would promise an install that never comes.
const policyLine = a.update_type === 'manual'
? 'Set to manual โ this app updates only when you press Update.'
- : 'Set to automatic โ new builds install on their own, after a recovery snapshot.';
+ : (this.autoAttemptFailed(a)
+ ? 'Set to automatic, but this build failed to apply and was rolled back โ it won\'t be retried. Press Update to try again, or wait for the next build.'
+ : 'Set to automatic โ new builds install on their own, after a recovery snapshot.');
versionSection = `
Version
${badge} ${cur}${avail ? ` โ${avail}` : ''}
${policyLine}
`;
@@ -594,6 +599,15 @@ class UpdaterPage {
return `
${versionSection}${security}${recovery}${history}
`;
}
+ // True when this app's AVAILABLE build is the one the auto-updater already
+ // attempted and rolled back (the one-shot no-retry stamp). Such an update is
+ // effectively manual now: it sits until a person retries or a newer build
+ // ships. Both this page and the Overview board branch on it.
+ autoAttemptFailed(a) {
+ return !!(a && a.update_available && a.update_type !== 'manual'
+ && a.auto_attempted_digest && a.auto_attempted_digest === a.available_digest);
+ }
+
// "automatic" marker for a History entry. Only automatic runs are labelled โ
// a hand-pressed update needs no explanation, and entries written before the
// trigger field existed carry none, so silence is also the honest default.
@@ -657,11 +671,14 @@ class UpdaterPage {
// restating a config value: all / some / none on automatic.
// Nothing installs itself while the scan that finds updates is off, so with
// iv === 0 the policy is moot and claiming otherwise would be a lie.
+ // The window says WHEN: checks run all day, installs land inside it.
+ const win = this.updates && this.updates.auto_window;
+ const winBit = (win && win !== 'always') ? ` during ${this.escape(win).replace('-', 'โ')}` : '';
const auto = this.apps.filter((a) => a.update_type !== 'manual').length;
let autoBit = '';
if (iv === 0 || !this.apps.length) autoBit = '';
- else if (auto === this.apps.length) autoBit = ' ยท updates install automatically';
- else if (auto) autoBit = ` ยท ${auto} of ${this.apps.length} apps install updates automatically`;
+ else if (auto === this.apps.length) autoBit = ` ยท updates install automatically${winBit}`;
+ else if (auto) autoBit = ` ยท ${auto} of ${this.apps.length} apps install updates automatically${winBit}`;
else autoBit = ' ยท updates wait for you';
return `
` +
`Checked automatically ยท last checked ${last}${nextBit}${autoBit}` +
diff --git a/docs/roadmap/app-version-updater-and-cve.md b/docs/roadmap/app-version-updater-and-cve.md
index ddf6264..9079929 100644
--- a/docs/roadmap/app-version-updater-and-cve.md
+++ b/docs/roadmap/app-version-updater-and-cve.md
@@ -143,7 +143,14 @@ Shipped a shape the original sketch didn't consider: the policy is **per app**,
Why not the sketched `off|security|all` severity split: severity is a property of the *CVE data*, which is only as complete as Trivy's per-image scan, whereas "do I let this app move on its own?" is a property of the *app* โ nextcloud and a DNS blocker are not the same risk. Per-app also composes with the CVE work instead of competing with it: a future `security` value can be added per app without changing the master switch or the enqueuer's shape.
-**Still to soak:** the honest gap below is unchanged โ apply/revert has been exercised in helper-level tests, not end-to-end on a live install with a real pending update. Auto-update inherits exactly that risk, which is why every attempt still snapshots first and why the digest stamp makes a bad build a one-time event rather than a loop.
+**Soaked 2026-08-11:** the first live end-to-end auto-update (trivy 0.72.0โ0.73.0) ran on a real install. Its first attempt exposed and fixed a latent dynamic-scoping bug (a callee's undeclared `while read app` loop blanked the updater's `app` local mid-update โ pre-existing, had also corrupted a manual update's History on 08-10); the retry completed cleanly with `trigger:auto` and exact digest refs.
+
+**Refinements (2026-08-11), after the "hands-off background" review:**
+- **Install window** โ `CFG_UPDATER_WINDOW` (HH:MM-HH:MM host local time, wraps midnight, `always`; default **06:00-08:00**, right after the 05:00 backup cron). Gates only the *enqueue* in `updaterApplyAuto`; scans keep their all-day cadence so detection stays current and pending updates visibly wait. Malformed values fail closed (hold updates) and are rejected by the WebUI validator; the raw value is surfaced in `updates.json` (`auto_window`) so the UI states when installs land. Ordering rationale: backups nightly at 05:00 โ updates 06:00-08:00, and every update still takes its own pre-update snapshot regardless (fail-closed), so the window is about *quiet hours*, not safety.
+- **Check now forces a live registry lookup** โ `updater check` (non-`auto`) sets `UPDATER_REGISTRY_FORCE=1`; previously the flag existed but nothing set it, so the button silently reused the 6h digest cache. Force also overrides `CFG_UPDATER_REGISTRY_INTERVAL=0`, making 0 mean "manual-only" as originally intended.
+- **Registry stamp out of /tmp** โ the task-processor unit runs with systemd `PrivateTmp`, so the /tmp stamp existed once per namespace (daemon vs CLI, daemon's resetting every restart). Now at `/logs/updater_registry_checked`.
+- **Failed-auto honesty** โ the scan emits each app's `auto_attempted_digest` (the one-shot no-retry stamp). When it matches `available_digest`, the UI stops claiming "installs automatically": the per-app detail says the build failed and won't retry, the fleet row gets an `auto failed` chip, and the Overview board counts it as *waiting for you* (warn) instead of quietly-installing (ok).
+- **`CFG_TIMEZONE` label corrected** โ it only sets containers' TZ; scheduled tasks follow the host clock. The old "System Timezone - Timezone for scheduled tasks" label promised a knob that never existed.
## 7. Build phases (each independently shippable)
diff --git a/scripts/cli/commands/updater/cli_updater_auto.sh b/scripts/cli/commands/updater/cli_updater_auto.sh
index 80e61f0..f93b2a9 100644
--- a/scripts/cli/commands/updater/cli_updater_auto.sh
+++ b/scripts/cli/commands/updater/cli_updater_auto.sh
@@ -6,9 +6,12 @@
# build available?", `updaterApplyApp` answers "how do I install it safely?",
# and this file answers "should I install it without being asked?".
#
-# Policy is per app, with a fleet-wide master switch:
+# Policy is per app, with a fleet-wide master switch and a daily install window:
# CFG__UPDATE_TYPE = auto | manual (per app, default auto)
# CFG_UPDATER_AUTO = true | false (master switch, default true)
+# CFG_UPDATER_WINDOW = HH:MM-HH:MM (host local time, default 06:00-08:00
+# โ right after the 05:00 backups;
+# 'always' = any time)
# Precedence mirrors the established backup-strategy shape (per-app override on
# top of a global default): the master switch can only ever make things MORE
# manual, so "turn auto-updates off" is one flip, not 33 config edits.
@@ -31,6 +34,32 @@ _updaterAutoGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updat
_updaterAutoDir() { echo "$(_updaterAutoGenDir)/auto"; }
_updaterAutoStamp() { echo "$(_updaterAutoDir)/$1.digest"; } # $1=app
+# Is the automatic-update window open right now? CFG_UPDATER_WINDOW is
+# HH:MM-HH:MM in the HOST's local time (the same clock cron and the daemon run
+# on โ deliberately NOT CFG_TIMEZONE, which only sets the containers' TZ), or
+# 'always'. start > end crosses midnight (22:00-02:00); start == end means the
+# window never closes. Scans are not gated by this โ only the enqueue is, so
+# detection stays current all day and pending updates simply wait.
+# Malformed values FAIL CLOSED (hold updates): the user typed something trying
+# to restrict when updates land, and ignoring it would violate that intent โ
+# the Updates page shows the raw window value, so a typo is visible, and the
+# WebUI validator rejects bad values before they ever land in the config.
+updaterInWindow()
+{
+ local win="${CFG_UPDATER_WINDOW:-always}"
+ [[ "$win" == "always" ]] && return 0
+ [[ "$win" =~ ^([01][0-9]|2[0-3]):([0-5][0-9])-([01][0-9]|2[0-3]):([0-5][0-9])$ ]] || return 1
+ local now s e
+ now=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
+ s=$(( 10#${BASH_REMATCH[1]} * 60 + 10#${BASH_REMATCH[2]} ))
+ e=$(( 10#${BASH_REMATCH[3]} * 60 + 10#${BASH_REMATCH[4]} ))
+ if (( s < e )); then
+ (( now >= s && now < e ))
+ else
+ (( now >= s || now < e )) # wraps midnight; s == e -> whole day
+ fi
+}
+
# Effective update policy for one app -> "auto" | "manual".
# 1. master switch off -> manual (everything, no exceptions)
# 2. per-app CFG__UPDATE_TYPE
@@ -85,6 +114,17 @@ updaterApplyAuto()
local upd; upd="$(_updaterAutoGenDir)/updates.json"
[[ -f "$upd" ]] || return 0
+ # The window gates the ENQUEUE only โ the scan that got us here already ran.
+ # Quiet unless something is actually waiting, so the daemon's half-hourly
+ # no-op scans don't fill the log with window notices.
+ if ! updaterInWindow; then
+ local n_waiting
+ n_waiting="$(jq -r '[.apps[]? | select(.update_available == true)] | length' "$upd" 2>/dev/null)"
+ [[ "${n_waiting:-0}" -gt 0 ]] && \
+ isNotice "$n_waiting update(s) are waiting for the automatic update window (${CFG_UPDATER_WINDOW:-always})."
+ return 0
+ fi
+
local auto_dir; auto_dir="$(_updaterAutoDir)"
[[ -d "$auto_dir" ]] || runFileOp mkdir -p "$auto_dir" 2>/dev/null
diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh
index 93b3169..8f64619 100644
--- a/scripts/cli/commands/updater/cli_updater_commands.sh
+++ b/scripts/cli/commands/updater/cli_updater_commands.sh
@@ -41,6 +41,12 @@ cliHandleUpdaterCommands()
(( _now - _last < scan_interval * 60 )) && return 0
fi
fi
+ # An explicit check (the Check-now button, or `updater check` by
+ # hand) forces a live registry lookup. Without this the button
+ # silently reused the 6-hour digest cache, so "Check now" could not
+ # find a build the user knows just shipped. The daemon's background
+ # `check auto` stays throttled.
+ [[ "$app" != "auto" ]] && export UPDATER_REGISTRY_FORCE=1
# Quick + safe โ just regenerates the read-only data files. Source
# the generator explicitly if the lazy loader hasn't mapped it yet
# (new file; the array regen self-heals it on deploy, this covers
diff --git a/scripts/cli/commands/updater/cli_updater_header.sh b/scripts/cli/commands/updater/cli_updater_header.sh
index 31b4f4b..d4f5f21 100644
--- a/scripts/cli/commands/updater/cli_updater_header.sh
+++ b/scripts/cli/commands/updater/cli_updater_header.sh
@@ -21,8 +21,10 @@ cliShowUpdaterHelp()
echo ""
echo "Automatic updates: each check enqueues the update for every app set to"
echo "CFG__UPDATE_TYPE=auto (the default) โ the same snapshot-first apply"
- echo "as pressing Update, recorded in History as automatic. Set an app to"
- echo "'manual' on its Configure page to hold it back, or CFG_UPDATER_AUTO=false"
- echo "to hold back every app. A build that fails is never auto-retried."
+ echo "as pressing Update, recorded in History as automatic. They install only"
+ echo "inside CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after"
+ echo "the nightly backups); checks still run all day. Set an app to 'manual'"
+ echo "on its Configure page to hold it back, or CFG_UPDATER_AUTO=false to"
+ echo "hold back every app. A build that fails is never auto-retried."
echo ""
}
diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh
index ed45d52..1818222 100644
--- a/scripts/source/files/arrays/function_manifest.sh
+++ b/scripts/source/files/arrays/function_manifest.sh
@@ -927,6 +927,7 @@ declare -gA LP_FN_MAP=(
[updaterComposePull]="cli/commands/updater/cli_updater_commands.sh"
[updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh"
+ [updaterInWindow]="cli/commands/updater/cli_updater_auto.sh"
[updaterLastUpdateFrom]="cli/commands/updater/cli_updater_commands.sh"
[updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh"
@@ -1940,6 +1941,7 @@ declare -gA LP_FN_ROOT=(
[updaterComposePull]="scripts"
[updaterDisplayVersion]="scripts"
[updaterInspectLocal]="scripts"
+ [updaterInWindow]="scripts"
[updaterLastUpdateFrom]="scripts"
[updaterPrimaryImage]="scripts"
[updaterRecordHistory]="scripts"
@@ -2986,6 +2988,7 @@ _updaterCleanImageRef() { unset -f _updaterCleanImageRef; __lpAutoload "${instal
updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterComposePull "$@"; }
updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; }
updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; }
+updaterInWindow() { unset -f updaterInWindow; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterInWindow "$@"; }
updaterLastUpdateFrom() { unset -f updaterLastUpdateFrom; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterLastUpdateFrom "$@"; }
updaterPrimaryImage() { unset -f updaterPrimaryImage; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterPrimaryImage "$@"; }
updaterRecordHistory() { unset -f updaterRecordHistory; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRecordHistory "$@"; }
diff --git a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh
index 8e4c246..75a5675 100755
--- a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh
+++ b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh
@@ -177,7 +177,7 @@ PORTEOF
"category": "general",
"label": "Updates",
"type": "select",
- "tooltip": "Automatic installs a new image build as soon as one is published โ the app is snapshotted first and rolled back automatically if the update fails. Manual leaves it listed under Updates until you press Update.",
+ "tooltip": "Automatic installs new image builds during the daily update window (set in Settings โ Updater) โ the app is snapshotted first and rolled back automatically if the update fails. Manual leaves it listed under Updates until you press Update.",
"options": [
{"value": "auto", "label": "Automatic (recommended)"},
{"value": "manual", "label": "Manual โ I'll press Update"}
diff --git a/scripts/webui/data/generators/config/webui_update_config.sh b/scripts/webui/data/generators/config/webui_update_config.sh
index 748cb58..123715b 100755
--- a/scripts/webui/data/generators/config/webui_update_config.sh
+++ b/scripts/webui/data/generators/config/webui_update_config.sh
@@ -84,6 +84,14 @@ webuiValidateConfigValue() {
isError " Invalid timezone format for $var_name"
fi
;;
+ CFG_UPDATER_WINDOW)
+ # 'always' or HH:MM-HH:MM (24h; start > end wraps midnight). The
+ # enqueuer fails CLOSED on a malformed value, so reject bad input
+ # here before it can silently hold every automatic update.
+ if ! echo "$var_value" | grep -qE '^always$|^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$'; then
+ isError " $var_name must be 'always' or HH:MM-HH:MM (e.g. 06:00-08:00)"
+ fi
+ ;;
CFG_NETWORK_SUBNET)
# Validate CIDR format
if ! echo "$var_value" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then
diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh
index 2688d37..e52851a 100644
--- a/scripts/webui/data/generators/updater/webui_updater_scan.sh
+++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh
@@ -141,9 +141,16 @@ webuiUpdaterScan() {
# (the "Check now" button) forces a live pull.
local prev_json="$out_dir/updates.json"
local reg_interval="${CFG_UPDATER_REGISTRY_INTERVAL:-360}"
- local reg_stamp="/tmp/libreportal_updater_registry_checked"
+ # Manager-owned persistent path, NOT /tmp: the task-processor service runs
+ # with systemd PrivateTmp, so a /tmp stamp existed once per namespace โ
+ # the daemon and the CLI each kept their own 6h clock, and the daemon's
+ # reset on every service restart. logs/ is manager-writable in both.
+ local reg_stamp="${docker_dir:-/libreportal-system}/logs/updater_registry_checked"
local do_registry=1
[ "$reg_interval" = "0" ] && do_registry=0
+ # Forced (an explicit Check now): always a live lookup, even at interval 0
+ # โ that setting means "only when I press the button", not "never".
+ [ -n "${UPDATER_REGISTRY_FORCE:-}" ] && do_registry=1
if [ -z "${UPDATER_REGISTRY_FORCE:-}" ] && [ "$do_registry" = "1" ] && [ -f "$reg_stamp" ]; then
local _rn _rl; _rn=$(date +%s); _rl=$(stat -c '%Y' "$reg_stamp" 2>/dev/null || echo 0)
(( _rn - _rl < reg_interval * 60 )) && do_registry=0
@@ -214,6 +221,14 @@ webuiUpdaterScan() {
declare -F updaterAppPolicy >/dev/null 2>&1 && policy="$(updaterAppPolicy "$app")"
fi
+ # The digest the auto-updater last ATTEMPTED for this app (its one-shot
+ # no-retry stamp). Exposed so the WebUI can tell "installing automatically"
+ # from "the automatic attempt failed and was rolled back โ this one now
+ # waits for you". Without it a failed auto-update looked identical to a
+ # pending one, and the page promised an install that would never come.
+ local auto_attempted=""
+ [ -f "$out_dir/auto/$app.digest" ] && auto_attempted="$(cat "$out_dir/auto/$app.digest" 2>/dev/null)"
+
if [ "$have_jq" = "1" ]; then
jq -cn \
--arg name "$app" --arg displayName "$app" --arg type "$vtype" \
@@ -222,13 +237,14 @@ webuiUpdaterScan() {
--arg available_image "$anchor" --arg available_version "$avail_ver" \
--arg available_digest "$avail_dig" --argjson update_available "$update_available" \
--arg last_checked "$now" --argjson services "$svcs" \
- --arg update_type "$policy" \
+ --arg update_type "$policy" --arg auto_attempted "$auto_attempted" \
'{name:$name,displayName:$displayName,type:$type,channel:$channel,
current_image:$current_image,current_version:$current_version,current_digest:$current_digest,
available_image:$available_image,
available_version:(if $available_version=="" then null else $available_version end),
available_digest:$available_digest,update_available:$update_available,
update_type:$update_type,
+ auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end),
scanned:true,last_checked:$last_checked,services:$services}' \
>> "$objs" 2>/dev/null
else
@@ -244,13 +260,18 @@ webuiUpdaterScan() {
# the auto-scan cadence, so the display needs no separate config fetch.
local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}"
[[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30
+ # The install window rides along so the WebUI can say WHEN automatic
+ # updates land, not just that they will. Raw value on purpose: a malformed
+ # window (which fails closed in the enqueuer) is then visible on the page.
+ local auto_window="${CFG_UPDATER_WINDOW:-always}"
local tmp; tmp="$(mktemp)"
if [ "$have_jq" = "1" ]; then
- jq -s --arg now "$now" --argjson iv "$scan_interval" '{generated_at:$now, scan_interval_minutes:$iv, apps:.}' "$objs" > "$tmp" 2>/dev/null \
- || printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [] }\n' "$now" "$scan_interval" > "$tmp"
+ jq -s --arg now "$now" --argjson iv "$scan_interval" --arg win "$auto_window" \
+ '{generated_at:$now, scan_interval_minutes:$iv, auto_window:$win, apps:.}' "$objs" > "$tmp" 2>/dev/null \
+ || printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [] }\n' "$now" "$scan_interval" "$auto_window" > "$tmp"
else
- { printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [' "$now" "$scan_interval"
+ { printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [' "$now" "$scan_interval" "$auto_window"
paste -sd, "$objs"; printf '] }\n'; } > "$tmp"
fi
rm -f "$objs"