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_idfrom tracked PHP, JS, and CSS file metadataPolling the local
/versionendpoint every 60 secondsComparing the loaded build ID against the current server build ID
Showing a
UI refresh requiredmodal 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 availablemodal when appropriate
Purpose:
Notify users about newer releases
Support branch-based prerelease workflows
Detect newer commits on development branches
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:
UI build-id polling detects whether the web UI files changed and prompts the browser to reload.
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,phpExcludes directory:
cache/Excludes file:
view_diag_logs.phpRecord format:
relative/path|mtime|sizeBuild 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_IDvs serverui_build_idFallback: loaded
window.WSPRRYPI_UI_VERSIONvs serverui_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
maintargets upstreammainCommit differences alone do not create an update notification
mainrequires a newer tagged semantic GitHub releaseIf upstream
mainis ahead but no newer release exists, status becomes:
main_commit_diff_without_release
Non-Main Branch Behavior
develtargets upstreamdevelIf local
develcommit is reachable from upstreammain, it targetsmainIf upstream
develis missing, it falls back tomainOther branches target the same-name upstream branch
If same-name upstream branch is missing, they fall back to
develNon-main branches allow commit-based update notifications
Detached or Unknown Branch Behavior
selectDetachedOrUnknownUpdateBranch()probesmain, thendevelIt 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 matchGitHub compare direction is:
currentSha...targetHeadSha
GitHub compare status
aheadmeans the target branch contains the installed commit and additional newer commits, so update is availableidenticalmeans no updatebehindanddivergedare treated as local-ahead/no-updateEmpty 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
rcto newerrcDifferent prerelease channels are ignored by default
Versions with build metadata normally fall back to commit comparison, except where
mainrelease-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:
UI refresh required
Confirm button:
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
#confirmModalwith:
backdrop: "static"
keyboard: false
Marks ownership with:
modalEl.dataset.updateCheckActive = "true"
Stores modal state in localStorage using:
wsprrypi.updateModalState
Suppresses repeat modal popups for the same identity for two hours:
UPDATE_MODAL_RATE_LIMIT_MS = 2 * 60 * 60 * 1000;
Modal identity is:
{
"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 reasondismissedClose/hidden while active: writes reason
dismissedView release: writes reasonopened, hides modal, opens release URLNever check again: writes reasondismissed, sets:
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:
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
uiBuildPollTimerprevents duplicate UI polling intervalsgithubUpdatePollTimerprevents duplicate GitHub polling intervalsuiBuildVersionCheckRunningprevents overlapping UI build checksuiRefreshPromptActiveprevents duplicate UI reload promptsdata-update-check-activeprotects shared modal ownershipreleaseUpdateCheckModalOwnership()clears update modal handlers before generic confirmation dialogs reuse the modal/version.phpsends:
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
mainfrom reporting unreleased commits as user-facing updates