--- orphan: true --- # WsprryPi Automatic Update Polling ## Architecture Overview WsprryPi uses two independent background update systems that share common UI infrastructure but serve different purposes. ### User Interface Build Polling The UI build poller detects local web UI file changes and prompts the browser to reload when the active interface is outdated. It works by: * Generating a `ui_build_id` from tracked PHP, JS, and CSS file metadata * Polling the local `/version` endpoint every 60 seconds * Comparing the loaded build ID against the current server build ID * Showing a `UI refresh required` modal when they differ This mechanism is entirely local and does not contact GitHub. Purpose: * Detect live UI changes during development * Force asset cache invalidation * Reload stale browser tabs after UI updates ### GitHub Application Update Polling The GitHub update poller checks whether the installed WsprryPi build is behind upstream releases or branch commits. It works by: * Polling update metadata once per hour * Fetching GitHub release and branch information * Comparing the installed SHA/version against upstream targets * Applying branch-aware update policy rules * Showing an `Update available` modal when appropriate Purpose: * Notify users about newer releases * Support branch-based prerelease workflows * Detect newer commits on development branches ### Shared Infrastructure Both systems share: * The `/version` endpoint * Shared Bootstrap modal infrastructure * Local storage persistence * Duplicate interval guards * Modal suppression/retry logic However, they intentionally use separate: * Poll timers * Comparison logic * Dismissal state * Update policies * User-facing messaging ## Technical Overview Current implementation lives primarily in `WsprryPi-UI/data/site.js`, with UI build metadata from `WsprryPi-UI/data/ui_version.php` and `/version` metadata from `src/web_server.cpp` or `WsprryPi-UI/data/version.php`. ### Two Separate Pollers WsprryPi has two update mechanisms: 1. UI build-id polling detects whether the web UI files changed and prompts the browser to reload. 2. GitHub/app update polling checks whether the installed WsprryPi build is behind an upstream GitHub branch or release. They share the `/version` endpoint and the shared Bootstrap `#confirmModal`, but they have separate timers, comparison rules, cache behavior, and dismissal state. ## UI Build Polling UI build identity is generated by `getWsprryPiUiBuildId()` in `ui_version.php`. It hashes records for tracked UI files: * Extensions: `css`, `js`, `php` * Excludes directory: `cache/` * Excludes file: `view_diag_logs.php` * Record format: `relative/path|mtime|size` * Build ID format: `mtime-<16-char-sha256-prefix>` The loaded page receives: ```js window.WSPRRYPI_UI_VERSION window.WSPRRYPI_UI_BUILD_ID ``` from `header.php`. Static asset URLs use `wsprrypiAssetUrl()`, which appends: ```text ?v= ``` This busts CSS/JS/font cache when tracked UI files change. Runtime polling is controlled by: ```js const UI_BUILD_POLL_INTERVAL_MS = 60 * 1000; initUiBuildChangePolling(); checkUiBuildVersion(); maybePromptForUiRefresh(); refreshUiForVersion(); ``` `initUiBuildChangePolling()` starts one interval every 60 seconds. It also binds `visibilitychange`; when the document becomes visible again, `checkUiBuildVersion()` runs immediately. Duplicate intervals are prevented by: ```js uiBuildPollTimer !== null ``` `checkUiBuildVersion()` fetches `/version` through: ```js getJsonWithEndpointFallback(VERSION_ENDPOINT) ``` It only calls `maybePromptForUiRefresh()` if the response includes `ui_build_id` or `ui_version`. ```js uiBuildVersionCheckRunning ``` prevents overlapping checks. `maybePromptForUiRefresh()` compares: * Preferred: loaded `window.WSPRRYPI_UI_BUILD_ID` vs server `ui_build_id` * Fallback: loaded `window.WSPRRYPI_UI_VERSION` vs server `ui_version` If different, it shows a modal titled: ```text UI refresh required ``` Confirm calls: ```js refreshUiForVersion(serverVersion, serverBuildId) ``` which reloads via: ```text current-url?ui_refresh= ``` using: ```js window.location.replace() ``` Cancel or modal hidden suppresses repeat prompts for that same server build/version in memory: ```js dismissedUiRefreshBuildId dismissedUiRefreshVersion ``` This suppression is not persisted to `localStorage`. If `showConfirmationDialog()` fails to show the modal, it returns `false`; `uiRefreshPromptActive` remains `false`, so the next poll retries. If `#confirmModal` is missing, the prompt is deferred and retried later. ## GitHub/App Update Polling GitHub/app update checks are driven by: ```js const GITHUB_UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; initGithubUpdatePolling(); updateWsprryPiVersion(); checkForWsprryPiUpdate(); buildWsprryPiUpdateResult(); ``` `initGithubUpdatePolling()` starts one hourly interval and prevents duplicates with: ```js githubUpdatePollTimer !== null ``` `pageLoaded()` calls `updateWsprryPiVersion()` immediately, so the first check happens on load. The hourly timer repeats it afterward. `updateWsprryPiVersion()` fetches `/version`, updates the footer `#versionText`, calls `maybePromptForUiRefresh(response)`, then calls `checkForWsprryPiUpdate(response)`. Backend `/version` includes structured metadata: ```json { "wspr_version": "... display text ...", "ui_version": "...", "wspr_version_raw": "...", "wspr_version_parsed": {}, "wspr_branch": "...", "wspr_branch_state": "branch|detached|unknown", "wspr_display_branch": "...", "wspr_exe_version": "...", "wspr_commit": "...", "wspr_build_dirty": false, "wspr_build_dirty_state": {} } ``` `parseWsprryPiVersionResponse()` prefers structured fields. Display-string parsing is legacy fallback. ## GitHub Comparison Flow GitHub API base: ```js const UPDATE_CHECK_API_BASE = "https://api.github.com/repos/WsprryPi/WsprryPi"; ``` Requests use: ```js fetchGithubJson(url, { cache: "no-store" }) ``` with: ```text Accept: application/vnd.github+json ``` Core functions: ```js fetchGithubReleases() summarizeSemanticReleases() selectGithubUpdateBranch() lookupGithubBranch() compareGithubCommits() buildSemanticVersionUpdateResult() buildCommitBasedWsprryPiUpdateResult() ``` Successful results are cached for one hour: ```js UPDATE_CHECK_CACHE_TTL_MS = 60 * 60 * 1000; ``` Failure results are rate-limited for five minutes: ```js UPDATE_CHECK_FAILURE_RATE_LIMIT_MS = 5 * 60 * 1000; ``` Manual `Check now` uses: ```js forceUpdateCheckNow() checkForWsprryPiUpdate(response, { bypassCache: true }) ``` This bypasses both success cache and failure rate limit, then writes fresh cache state. ## Update Policy ```js branchAllowsCommitUpdate(branch) ``` returns `false` only for `main`. ### Main Branch Behavior * `main` targets upstream `main` * Commit differences alone do not create an update notification * `main` requires a newer tagged semantic GitHub release * If upstream `main` is ahead but no newer release exists, status becomes: ```text main_commit_diff_without_release ``` ### Non-Main Branch Behavior * `devel` targets upstream `devel` * If local `devel` commit is reachable from upstream `main`, it targets `main` * If upstream `devel` is missing, it falls back to `main` * Other branches target the same-name upstream branch * If same-name upstream branch is missing, they fall back to `devel` * Non-main branches allow commit-based update notifications ### Detached or Unknown Branch Behavior * `selectDetachedOrUnknownUpdateBranch()` probes `main`, then `devel` * It only selects a target if the local SHA is reachable from that upstream branch * Otherwise it fails with: ```text detached_target_unknown ``` ### SHA Comparison Behavior * `updateCheckShaMatches()` accepts full SHA equality or short SHA prefix match * GitHub compare direction is: ```text currentSha...targetHeadSha ``` * GitHub compare status `ahead` means the target branch contains the installed commit and additional newer commits, so update is available * `identical` means no update * `behind` and `diverged` are treated as local-ahead/no-update * Empty commits on a tracked non-main upstream branch are detected because GitHub reports the branch as `ahead` ### Tagged Release Behavior * Stable local semantic versions compare only against the latest stable GitHub release * Stable builds do not upgrade to prereleases * Prerelease builds first compare against newer stable releases * Then they compare against newer prereleases in the same channel, for example `rc` to newer `rc` * Different prerelease channels are ignored by default * Versions with build metadata normally fall back to commit comparison, except where `main` release-only policy applies ### Commit-Based Prerelease Behavior * Non-main prerelease or build-metadata versions can surface branch/SHA updates * The modal treats these as branch/channel updates, not exact tagged release updates * The primary action button is hidden unless the result is a tagged semantic release update ## Modal Behavior ### UI Build Reload Modal * Uses `showConfirmationDialog()` * Title: ```text UI refresh required ``` * Confirm button: ```text Refresh ``` * Cancel suppresses the same build/version for the current page lifetime * Hidden while active also records the same in-memory dismissal * Failed show attempts are retried on later polls ### App Update Modal * Uses `showWsprryPiUpdateModal()` * Uses `#confirmModal` with: ```js backdrop: "static" keyboard: false ``` * Marks ownership with: ```js modalEl.dataset.updateCheckActive = "true" ``` * Stores modal state in localStorage using: ```text wsprrypi.updateModalState ``` * Suppresses repeat modal popups for the same identity for two hours: ```js UPDATE_MODAL_RATE_LIMIT_MS = 2 * 60 * 60 * 1000; ``` Modal identity is: ```json { "branch": "...", "currentSha": "...", "targetSha": "...", "updateUrl": "..." } ``` A different target SHA, remote version, branch, current SHA, or update URL can trigger an immediate popup. The app update modal has these user paths: * `Dismiss`: writes reason `dismissed` * Close/hidden while active: writes reason `dismissed` * `View release`: writes reason `opened`, hides modal, opens release URL * `Never check again`: writes reason `dismissed`, sets: ```text wsprrypi.updateCheckDisabled = "true" ``` and hides the modal. Tagged release updates show a release link and `View release`. Commit/branch updates show branch/SHA context and hide the confirm action. Storage events keep multiple tabs in sync. If another tab disables checks or dismisses/opens the same modal identity, the active modal hides. ## Local Storage Keys Current keys: ```text wsprrypi.updateCheck:::: wsprrypi.updateCheckFailure:::: wsprrypi.updateCheckDisabled wsprrypi.updateModalState ``` There are no current `wsprrypi.updateDismissed*` localStorage keys. UI refresh dismissal is held only in JS variables, and app update dismissal is represented by `wsprrypi.updateModalState`. ### Clear Update-Check State for Testing ```js Object.keys(localStorage) .filter((k) => k.startsWith("wsprrypi.updateCheck") || k.startsWith("wsprrypi.updateCheckFailure") || k === "wsprrypi.updateModalState" || k === "wsprrypi.updateCheckDisabled" ) .forEach((k) => localStorage.removeItem(k)); ``` ### Force GitHub Check ```js forceUpdateCheckNow(); ``` ### Force UI Reload Prompt ```js maybePromptForUiRefresh({ ui_build_id: "test-" + Date.now(), ui_version: window.WSPRRYPI_UI_VERSION }); ``` ### Reset In-Memory UI Prompt Suppression ```js dismissedUiRefreshBuildId = null; dismissedUiRefreshVersion = null; uiRefreshPromptActive = false; ``` ## Developer Workflows ### Run the Comparison Regression Test ```bash node src/tests/update_check_comparison_test.js ``` ### Simulate a Non-Main Branch Update ```bash git checkout my-feature git commit --allow-empty -m "test update polling" git push origin my-feature ``` Load an older local build from the same branch. The GitHub compare status should become `ahead`, causing an update notification. ### Test Main Branch Release Policy ```bash git checkout main git commit --allow-empty -m "test main ahead without release" git push origin main ``` A `main` commit difference alone should not show an app update. A newer tagged GitHub release is required. ### Useful Debug Console Calls ```js updateWsprryPiVersion(); forceUpdateCheckNow(); checkUiBuildVersion(); readUpdateModalState(); isUpdateCheckDisabled(); setUpdateCheckDisabled(false); ``` ## Safeguards * `uiBuildPollTimer` prevents duplicate UI polling intervals * `githubUpdatePollTimer` prevents duplicate GitHub polling intervals * `uiBuildVersionCheckRunning` prevents overlapping UI build checks * `uiRefreshPromptActive` prevents duplicate UI reload prompts * `data-update-check-active` protects shared modal ownership * `releaseUpdateCheckModalOwnership()` clears update modal handlers before generic confirmation dialogs reuse the modal * `/version.php` sends: ```text Cache-Control: no-store, no-cache, must-revalidate, max-age=0 ``` * GitHub API fetches use: ```js cache: "no-store" ``` * UI assets use build-id query parameters for cache busting * Branch-aware policy prevents `main` from reporting unreleased commits as user-facing updates