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:

window.WSPRRYPI_UI_VERSION
window.WSPRRYPI_UI_BUILD_ID

from header.php.

Static asset URLs use wsprrypiAssetUrl(), which appends:

?v=<ui_build_id>

This busts CSS/JS/font cache when tracked UI files change.

Runtime polling is controlled by:

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:

uiBuildPollTimer !== null

checkUiBuildVersion() fetches /version through:

getJsonWithEndpointFallback(VERSION_ENDPOINT)

It only calls maybePromptForUiRefresh() if the response includes ui_build_id or ui_version.

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:

UI refresh required

Confirm calls:

refreshUiForVersion(serverVersion, serverBuildId)

which reloads via:

current-url?ui_refresh=<serverBuildId-or-version>

using:

window.location.replace()

Cancel or modal hidden suppresses repeat prompts for that same server build/version in memory:

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:

const GITHUB_UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;

initGithubUpdatePolling();
updateWsprryPiVersion();
checkForWsprryPiUpdate();
buildWsprryPiUpdateResult();

initGithubUpdatePolling() starts one hourly interval and prevents duplicates with:

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:

{
  "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:

const UPDATE_CHECK_API_BASE = "https://api.github.com/repos/WsprryPi/WsprryPi";

Requests use:

fetchGithubJson(url, { cache: "no-store" })

with:

Accept: application/vnd.github+json

Core functions:

fetchGithubReleases()
summarizeSemanticReleases()
selectGithubUpdateBranch()
lookupGithubBranch()
compareGithubCommits()
buildSemanticVersionUpdateResult()
buildCommitBasedWsprryPiUpdateResult()

Successful results are cached for one hour:

UPDATE_CHECK_CACHE_TTL_MS = 60 * 60 * 1000;

Failure results are rate-limited for five minutes:

UPDATE_CHECK_FAILURE_RATE_LIMIT_MS = 5 * 60 * 1000;

Manual Check now uses:

forceUpdateCheckNow()
checkForWsprryPiUpdate(response, { bypassCache: true })

This bypasses both success cache and failure rate limit, then writes fresh cache state.

Update Policy

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:

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:

detached_target_unknown

SHA Comparison Behavior

  • updateCheckShaMatches() accepts full SHA equality or short SHA prefix match

  • GitHub compare direction is:

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

Local Storage Keys

Current keys:

wsprrypi.updateCheck:<branchState>:<branch>:<sha>:<dirty-state>
wsprrypi.updateCheckFailure:<branchState>:<branch>:<sha>:<dirty-state>
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

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

forceUpdateCheckNow();

Force UI Reload Prompt

maybePromptForUiRefresh({
  ui_build_id: "test-" + Date.now(),
  ui_version: window.WSPRRYPI_UI_VERSION
});

Reset In-Memory UI Prompt Suppression

dismissedUiRefreshBuildId = null;
dismissedUiRefreshVersion = null;
uiRefreshPromptActive = false;

Developer Workflows

Run the Comparison Regression Test

node src/tests/update_check_comparison_test.js

Simulate a Non-Main Branch Update

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

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

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:

Cache-Control: no-store, no-cache, must-revalidate, max-age=0
  • GitHub API fetches use:

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