diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f0f885393..f053231ec 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -123,6 +123,10 @@ jobs: local-path: ./src/renderer/dist remote-path: /home/www/opencor.ws/app sync: full + # Note: we upload version.json last so that OpenCOR's Web app only reports a new version once all the other + # files have been uploaded. + ftp-mirror-options: --exclude-glob=assets/version.json + ftp-post-sync-commands: put -O /home/www/opencor.ws/app/assets ./src/renderer/dist/assets/version.json - name: Archive OpenCOR's Web app run: bun archive:web - name: Release OpenCOR's Web app diff --git a/.github/workflows/cddev.yml b/.github/workflows/cddev.yml index bba30d211..140556155 100644 --- a/.github/workflows/cddev.yml +++ b/.github/workflows/cddev.yml @@ -38,3 +38,7 @@ jobs: local-path: ./src/renderer/dist remote-path: /home/www/opencor.ws/appdev sync: full + # Note: we upload version.json last so that OpenCOR's Web app only reports a new version once all the other + # files have been uploaded. + ftp-mirror-options: --exclude-glob=assets/version.json + ftp-post-sync-commands: put -O /home/www/opencor.ws/appdev/assets ./src/renderer/dist/assets/version.json diff --git a/package.json b/package.json index 36408941b..63c9a8997 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/opencor/webapp.git" }, "type": "module", - "version": "1.20260924.0", + "version": "1.20260924.1", "engines": { "bun": ">=1.2.0" }, diff --git a/src/renderer/package.json b/src/renderer/package.json index 9f798a9ce..0fc720b48 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -42,7 +42,7 @@ }, "./style.css": "./dist/opencor.css" }, - "version": "1.20260924.0", + "version": "1.20260924.1", "libopencorVersion": "1.20260803.0", "scripts": { "build": "vite build && bun scripts/generate.version.ts", diff --git a/src/renderer/scripts/generate.version.ts b/src/renderer/scripts/generate.version.ts index 567a7db20..24a407388 100644 --- a/src/renderer/scripts/generate.version.ts +++ b/src/renderer/scripts/generate.version.ts @@ -12,16 +12,32 @@ const version = (JSON.parse(fs.readFileSync(packageJsonPath)) as { version: stri // Make sure that the dist/assets folder exists. -const distAssetsPath = path.join(__dirname, '../dist/assets'); +const distPath = path.join(__dirname, '../dist'); +const distAssetsPath = path.join(distPath, 'assets'); if (!fs.existsSync(distAssetsPath)) { fs.mkdirSync(distAssetsPath, { recursive: true }); } +// List the files that the Web app needs to refresh when force reloading it. +// Note: we exclude index.html (the Web app refreshes the page itself using its actual URL), version.json (it is always +// fetched with cache busting), stats.html (it is not part of the Web app), and libOpenCOR's files (their path +// contains libOpenCOR's version, so they never get stale). + +const files = (fs.readdirSync(distPath, { recursive: true }) as string[]) + .map((file) => file.split(path.sep).join('/')) + .filter( + (file) => + fs.statSync(path.join(distPath, file)).isFile() && + !['index.html', 'assets/version.json', 'stats.html'].includes(file) && + !file.startsWith('libopencor/') + ) + .sort(); + // Write the version file. -fs.writeFileSync(path.join(distAssetsPath, 'version.json'), JSON.stringify({ version }, null, 2)); +fs.writeFileSync(path.join(distAssetsPath, 'version.json'), JSON.stringify({ version, files }, null, 2)); // Log the generated version. -console.log(`Generated version.json with version ${version}.`); +console.log(`Generated version.json with version ${version} and ${files.length} files.`); diff --git a/src/renderer/src/common/version.ts b/src/renderer/src/common/version.ts index b1ce1f8eb..dbd7e9b87 100644 --- a/src/renderer/src/common/version.ts +++ b/src/renderer/src/common/version.ts @@ -13,6 +13,7 @@ const latestVersion = vue.ref(''); interface IVersionInfo { version: string; + files?: string[]; } const checkForUpdates = async (): Promise => { @@ -95,6 +96,64 @@ const startCheck = (): void => { } }; +// Force reload the Web app. +// Note: there is no standard way to force reload a page (location.reload(true) is non-standard and ignored by modern +// browsers). Also, our assets don't have a hash in their filenames (see vite.config.ts), so they may be stale in +// the HTTP cache too. So, we first fetch the page and all the files listed in the latest version.json file with +// cache: 'reload', which bypasses the HTTP cache and updates it, and then we reload the page. We do this within a +// given amount of time after which we reload the page anyway, so that the user is never left waiting forever. + +const FORCE_RELOAD_TIMEOUT = 30 * 1000; // 30 seconds. + +let forceReloading = false; + +const forceReload = async (): Promise => { + if (forceReloading) { + return; + } + + forceReloading = true; + + // Create an AbortController to abort the fetch requests if they take too long. + + const abortController = new AbortController(); + const timeoutId = window.setTimeout(() => { + abortController.abort(); + }, FORCE_RELOAD_TIMEOUT); + + // Refresh the given URL in the HTTP cache. + // Note: we need to read the body since the HTTP cache is only updated once the body has been fully received. + + const refresh = async (url: string): Promise => { + const response = await fetch(url, { cache: 'reload', signal: abortController.signal }); + + await response.blob(); + }; + + try { + // Retrieve the list of files used by the latest version of OpenCOR's Web app. + + const response = await fetch(`./assets/version.json?t=${Date.now()}`, { + cache: 'no-store', + signal: abortController.signal + }); + const versionInfo: IVersionInfo = response.ok ? await response.json() : { version: '' }; + + // Refresh the page and all the files. + + await Promise.allSettled([ + refresh(window.location.href), + ...(versionInfo.files ?? []).map((file) => refresh(new URL(file, document.baseURI).href)) + ]); + } catch (_error: unknown) { + // Ignore any error (including a timeout) and reload the page anyway. + } finally { + window.clearTimeout(timeoutId); + } + + window.location.reload(); +}; + // Export the version checking functions and state. -export { currentVersion, latestVersion, startCheck, updateAvailable }; +export { currentVersion, forceReload, latestVersion, startCheck, updateAvailable }; diff --git a/src/renderer/src/components/dialogs/UpdateAvailableDialog.vue b/src/renderer/src/components/dialogs/UpdateAvailableDialog.vue index 7004956b1..7fcd11076 100644 --- a/src/renderer/src/components/dialogs/UpdateAvailableDialog.vue +++ b/src/renderer/src/components/dialogs/UpdateAvailableDialog.vue @@ -1,31 +1,42 @@