/**
* Utility for checking and handling application updates in PWA environment.
*
* Utility for checking for updates and handling user notification either in
* PWA or service. Implements offline-first approach where all network failures
* are handled silently without showing any errors to the user.
*
* @license BSD, see LICENSE.md.
*/
/**
* Utility class for handling application updates.
*
* Provides methods to check for available updates by comparing the current
* application version (embedded in HTML) with the latest version available
* on the server (version.txt). All network operations fail silently to
* support offline usage.
*/
class UpdateUtil {
/**
* Check if an application update is available.
*
* Compares the current app version (from hidden input) with the server
* version (from version.txt). Fails silently on any network errors to
* support offline usage.
*
* @returns {Promise<boolean>} Promise that resolves to true if update
* is available, false otherwise. Always resolves (never rejects).
*/
async checkForUpdates() {
const self = this;
try {
const versionInput = document.getElementById("app-version");
if (!versionInput) {
// No version input found, assume no update needed
return false;
}
const currentVersion = versionInput.value;
if (!currentVersion || currentVersion === "EPOCH") {
// Invalid current version (development mode), assume no update needed
return false;
}
// Fetch latest version from server with cache busting
const response = await fetch(`/version.txt?t=${Date.now()}`, {
method: "GET",
cache: "no-cache",
headers: {
"Cache-Control": "no-cache",
},
});
if (!response.ok) {
// Network error or file not found, fail silently
return false;
}
const latestVersion = (await response.text()).trim();
if (!latestVersion) {
// Empty or invalid version file, assume no update
return false;
}
// Compare versions (both should be epoch timestamps)
const currentTimestamp = parseInt(currentVersion, 10);
const latestTimestamp = parseInt(latestVersion, 10);
if (isNaN(currentTimestamp) || isNaN(latestTimestamp)) {
// Invalid version format, assume no update
return false;
}
// Update available if server version is newer
return latestTimestamp > currentTimestamp;
} catch (error) {
// Any error (network, parsing, etc.) - fail silently
return false;
}
}
/**
* Show the update notice dialog to the user.
*
* Displays a modal dialog informing the user that an update is available
* and offering options to reload now or continue with current version.
*
* @param {Function} [saveCallback] - Optional callback to explicitly save
* current state before reload
* @param {boolean} [isUpToDate] - Whether the user is already up to date
* @returns {Promise<string>} Promise that resolves to 'reload' if user
* chooses to reload, 'continue' if user chooses to continue, or 'save_failed'
* if save callback fails. Never rejects.
*/
async showUpdateDialog(saveCallback = null, isUpToDate = false) {
const self = this;
return new Promise((resolve) => {
const dialog = document.getElementById("update-notice-dialog");
// Show/hide up-to-date message based on update status
const upToDateMessage = dialog.querySelector("#up-to-date-message");
upToDateMessage.style.display = isUpToDate ? "block" : "none";
const reloadButton = dialog.querySelector(".reload-button");
const continueButton = dialog.querySelector(".cancel-button");
const handleReload = (event) => {
event.preventDefault();
// Show loading indicator and hide buttons
const loadingIndicator = dialog.querySelector("#update-loading-indicator");
const buttonPanel = dialog.querySelector(".dialog-buttons");
loadingIndicator.style.display = "block";
buttonPanel.style.display = "none";
// Explicitly save current state before reloading if callback provided
if (saveCallback && typeof saveCallback === "function") {
try {
saveCallback();
} catch (error) {
// Show alert if save fails and don't reload
const message = "Failed to save your current work. " +
"Please try again or save manually before updating.";
alert(message);
console.error("Save callback failed, aborting reload:", error);
// Restore UI state
loadingIndicator.style.display = "none";
buttonPanel.style.display = "block";
resolve("save_failed");
return;
}
}
// Perform hard refresh operations
const promises = [];
// Clear all browser caches if supported
if ("caches" in window) {
const cachesClearPromise = caches.keys().then((names) => {
return Promise.all(names.map((name) => caches.delete(name)));
});
promises.push(cachesClearPromise);
} else {
promises.push(Promise.resolve());
}
// Update service worker if supported
if ("serviceWorker" in navigator) {
const serviceWorkerUpdatePromise = navigator.serviceWorker.getRegistrations()
.then((registrations) => {
return Promise.all(registrations.map((registration) => registration.update()));
});
promises.push(serviceWorkerUpdatePromise);
} else {
promises.push(Promise.resolve());
}
Promise.all(promises).then(() => {
setTimeout(() => {
window.location.reload();
resolve("reload");
}, 1000);
}).catch((error) => {
console.warn("Error during cache/service worker operations:", error);
setTimeout(() => {
window.location.reload();
resolve("reload");
}, 1000);
});
};
const handleContinue = (event) => {
event.preventDefault();
dialog.close();
resolve("continue");
};
const handleClose = () => {
resolve("continue");
};
reloadButton.addEventListener("click", handleReload);
continueButton.addEventListener("click", handleContinue);
dialog.addEventListener("close", handleClose);
dialog.showModal();
});
}
}
export {UpdateUtil};