From 2248aa14e572aef4d5f8c60b1b610addb5ec5692 Mon Sep 17 00:00:00 2001 From: Joey Kimsey Date: Sat, 12 Sep 2026 22:47:47 -0400 Subject: [PATCH] various bugfixes --- .env.example | 3 +++ .gitignore | 1 + CHANGELOG.md | 3 +++ README.md | 4 +++- build/defaultSite.mjs | 8 ++++++++ docs/releases.md | 6 +++--- electron.vite.config.mjs | 15 +++++++++++---- scripts/smoke.cjs | 31 ++++++++++++++++++++++++++++++- src/main/updateController.mjs | 25 +++++++++++++++++++------ src/main/updateIpc.mjs | 3 ++- src/preload/index.js | 5 +++++ src/renderer/index.html | 8 ++------ src/renderer/src/main.js | 22 ++++++++-------------- src/renderer/src/styles.css | 17 +++++++---------- src/renderer/src/updates.js | 18 ++++++++++++++++-- tests/defaultSite.test.mjs | 12 ++++++++++++ tests/updates.test.mjs | 32 +++++++++++++++++++++++++++++--- 17 files changed, 162 insertions(+), 51 deletions(-) create mode 100644 .env.example create mode 100644 build/defaultSite.mjs create mode 100644 tests/defaultSite.test.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd59ec4 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Non-secret default for the login forms and logged-out site branding. +# Copy to .env or set this variable before running dev/build/package commands. +CAPSULE_DEFAULT_SITE=https://ttp.joeykimsey.com diff --git a/.gitignore b/.gitignore index 93e4c23..21b6328 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ Thumbs.db .env .env.* +!.env.example *.pfx *.p12 *.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c608d..005b3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Configurable `CAPSULE_DEFAULT_SITE` for first-run login fields and logged-out branding, with the demo URL as its default and `.env.example` for setup. - Capsule Local 0.1.2 packages the custom Windows icon and shows the installed version in the dashboard update notice. - Opt-in Capsule Local automatic-update testing, with a 0.1.0 bootstrap, isolated unsigned test feed, and 0.1.1 update target. Public signing requirements remain enforced. - Capsule Local 0.1.1 displays “you have updated to 0.1.1” on the dashboard for manual installer upgrade testing. @@ -26,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Automatic update checks now request download approval in a blue info banner, then offer a separate restart/install action. The logged-out header uses the saved/default site's logo, and an empty Contact column keeps the footer theme switch centered. +- Removed the “Preview the desktop chrome” link from the sign-in screen. - Footer upper band is Contact Us on the left, dark mode in the middle, and More Info on the right, in three equal columns. Contact and Report a Bug stay off the menu until the connected site says those plugins are accepting submissions. - Footer copyright no longer lists the connected hostname. It is year plus “Powered by The Tempus Project.” - Profile page: space the white panel below the main nav, and cap the avatar at 200×200. diff --git a/README.md b/README.md index d69212f..c32dad2 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ npm run dev ## Package and update +Set `CAPSULE_DEFAULT_SITE=https://ttp.joeykimsey.com` in `.env` (copy `.env.example`) or in the shell before `npm run dev` or a build. This non-secret setting is embedded at build time and defaults to the demo URL above. It prefills both sign-in forms and supplies the logged-out logo; a saved or connected site takes precedence. It does not select the update feed or connect/sign in automatically. Rebuild installed packages after changing it. + For automatic-update testing without a signing certificate, use the separate `dist:test-bootstrap` and `dist:test` commands described in [Free automatic-update testing](docs/releases.md#free-automatic-update-testing). Original Local installations require one bootstrap installation; subsequent test updates use the in-app updater. `npm run dist:win` builds a per-user Windows x64 installer at `dist/local/Capsule-Local-0.1.1-x64-Setup.exe` (the filename follows the package version). This unsigned **Capsule Local** build has its own installation and session directory; automatic updates are disabled. To update a 0.1.0 test installation, close Capsule Local and run the 0.1.1 installer under the same Windows account. Reopen it and confirm the dashboard says “you have updated to 0.1.1”; the installer preserves app data. This manual test does not publish to the signed release feed. `npm run pack:win` produces an unpacked app for inspection. `npm test` checks updater behavior and release validation. -Public builds use `npm run release:win`, require a permanent HTTPS update URL and Windows signing identity, and go to `dist/release/`. They download updates in the background and offer **Restart to update**, including before sign-in. Closing the app normally does not install an update. A closed app checks after its next launch. +Public builds use `npm run release:win`, require a permanent HTTPS update URL and Windows signing identity, and go to `dist/release/`. Update-enabled builds check automatically 15 seconds after launch and every four hours. An available update shows a blue **Download update** banner; downloading requires that click. After verification, the blue banner offers **Restart to update**, including before sign-in. Closing the app normally does not install an update. A closed app checks after its next launch. Read [Packaging and releases](docs/releases.md) for signing variables, hosting requirements, the release automation entry point, and the installed-upgrade acceptance test. No public feed or signing credentials are configured in this checkout. diff --git a/build/defaultSite.mjs b/build/defaultSite.mjs new file mode 100644 index 0000000..4a87ac7 --- /dev/null +++ b/build/defaultSite.mjs @@ -0,0 +1,8 @@ +/** Validate the non-secret default site embedded in the renderer at build time. */ +export function defaultSite(value = 'https://ttp.joeykimsey.com') { + const url = new URL(value.trim()) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('CAPSULE_DEFAULT_SITE must be an HTTP(S) site URL without credentials, query, or fragment.') + } + return url.href.replace(/\/+$/, '') +} diff --git a/docs/releases.md b/docs/releases.md index 3855a90..b0ce812 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -50,9 +50,9 @@ Publishing `latest.yml` announces a release. Upload and verify both artifacts fi `src/main/updateController.mjs` owns the state machine. `updates.js` wires it into Electron, and `updateIpc.mjs` permits only the known top-level app window. The preload bridge exposes status, check, install, and a status subscription. The renderer receives no signing credentials, release tokens, site tokens, or updater configuration. -The updater automatically downloads a newer stable version. It validates checksums and the configured Windows publisher signature before reporting readiness. The user chooses **Restart to update** after saving work. Normal close, Windows shutdown, and logoff do not intentionally launch an update installer (`autoInstallOnAppQuit = false`). Reopening checks again and can reuse a valid cached download. No downgrade or prerelease is accepted. If a release is faulty, publish a higher version containing the reverted code; do not overwrite an existing installer or assume automatic rollback. +The updater automatically checks for a newer stable version, then shows a blue **Download update** banner. It does not download until the user clicks that button (`autoDownload = false`). It validates checksums and the configured Windows publisher signature before reporting readiness. The blue banner then offers **Restart to update** after saving work. Normal close, Windows shutdown, and logoff do not intentionally launch an update installer (`autoInstallOnAppQuit = false`). Reopening checks again and can reuse a valid cached download after download approval. No downgrade or prerelease is accepted. If a release is faulty, publish a higher version containing the reverted code; do not overwrite an existing installer or assume automatic rollback. -Network, metadata, signature, and download failures show a retryable error while leaving the installed app usable. The update panel is available on the login screen, so a broken site connection does not prevent a client update. Multiple app instances are prevented to avoid competing installs. Local and development builds never contact an update feed. +Network, metadata, signature, and download failures show a retryable error while leaving the installed app usable. The update panel is available on the login screen, so a broken site connection does not prevent a client update. Multiple app instances are prevented to avoid competing installs. Ordinary Local and development builds never contact an update feed; opt-in test-update builds use their isolated feed. ## 4. Sign a public release @@ -62,7 +62,7 @@ Set these variables in a secure release environment: - `CAPSULE_PUBLISHER_NAME`: the exact common name on the signing certificate. - `CSC_LINK`: a supported certificate location or base64 PFX, with `CSC_KEY_PASSWORD` when required; **or** `CSC_NAME`: the subject name of a signing identity already in the Windows certificate store, including a configured hardware-backed identity. -The certificate provider determines how its private key is accessed; not all certificates can be exported to a PFX. Store credentials in the CI secret store or machine certificate provider. Do not commit keys, `.env` files, or release credentials. This project does not load `.env` automatically. +The certificate provider determines how its private key is accessed; not all certificates can be exported to a PFX. Store credentials in the CI secret store or machine certificate provider. Do not commit keys, `.env` files, or release credentials. Packaging/publishing scripts require signing and upload credentials in their process environment. The Vite configuration reads `.env` only for the non-secret `CAPSULE_DEFAULT_SITE` renderer setting; it does not pass signing or upload secrets into the renderer. Run `npm run release:win`. This invalidates any prior ready marker, runs the tests, compiles the client, requires code signing, and builds to `dist/release/`. Missing URL, missing publisher, missing signing identity, or a signing failure prevents a publishable build. Signature verification stays enabled. Signing identity configuration must be consistent across releases; plan certificate/publisher changes before the old certificate expires. diff --git a/electron.vite.config.mjs b/electron.vite.config.mjs index d874e75..cd2c5a4 100644 --- a/electron.vite.config.mjs +++ b/electron.vite.config.mjs @@ -1,7 +1,14 @@ import { defineConfig } from 'electron-vite' +import { loadEnv } from 'vite' +import { defaultSite } from './build/defaultSite.mjs' -export default defineConfig({ - main: {}, - preload: {}, - renderer: {} +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), 'CAPSULE_') + return { + main: {}, + preload: {}, + renderer: { + define: { __CAPSULE_DEFAULT_SITE__: JSON.stringify(defaultSite(process.env.CAPSULE_DEFAULT_SITE ?? env.CAPSULE_DEFAULT_SITE)) } + } + } }) diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 6385979..2defc3d 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -37,15 +37,44 @@ app.on('browser-window-created', (_event, window) => { assert.equal(state.restartHidden, true) assert.match(state.label, /automatic updates are disabled/) assert.equal(app.getPath('userData'), join(profile, 'capsule-local')) + const chrome = await window.webContents.executeJavaScript(`(() => { + const cols = document.querySelector('.capsule-footer-cols').getBoundingClientRect() + const theme = document.querySelector('.capsule-footer-theme').getBoundingClientRect() + const logo = document.getElementById('header-logo') + return { + site: document.getElementById('login-site').value, + tokenSite: document.getElementById('token-site').value, + logo: logo.src, + contactBlank: getComputedStyle(document.getElementById('footer-contact-col')).visibility === 'hidden', + themeCentered: Math.abs((theme.left + theme.right) / 2 - (cols.left + cols.right) / 2) < 2 + } + })()`) + assert.equal(chrome.site, chrome.tokenSite) + assert.ok(chrome.site.startsWith('http')) + assert.equal(chrome.logo, chrome.site + '/images/logoWhite.png') + assert.equal(chrome.contactBlank, true) + assert.equal(chrome.themeCentered, true) const image = await window.webContents.capturePage() writeFileSync(join(__dirname, '../dist/local/smoke.png'), image.toPNG()) + window.webContents.send('capsule:updates:changed', { + status: 'available', currentVersion: app.getVersion(), version: '0.1.3', percent: 0 + }) + const offer = await window.webContents.executeJavaScript(`new Promise(resolve => setTimeout(() => { + const panel = document.getElementById('capsule-updates') + resolve({ blue: panel.classList.contains('is-update'), + download: !document.getElementById('update-download').hidden, + install: !document.getElementById('update-install').hidden }) + }, 100))`) + assert.deepEqual(offer, { blue: true, download: true, install: false }) + writeFileSync(join(__dirname, '../dist/local/smoke-available.png'), (await window.webContents.capturePage()).toPNG()) // Exercise the ready prompt with synthetic state; never invoke installation. window.webContents.send('capsule:updates:changed', { status: 'ready', currentVersion: app.getVersion(), version: '0.1.1', percent: 100 }) const readyVisible = await window.webContents.executeJavaScript(`new Promise(resolve => setTimeout(() => { const button = document.getElementById('update-install') - resolve(!button.hidden && document.getElementById('update-status').textContent.includes('Save your work')) + resolve(!button.hidden && document.getElementById('update-status').textContent.includes('Save your work') && + document.getElementById('capsule-updates').classList.contains('is-update') && document.getElementById('update-download').hidden) }, 100))`) assert.equal(readyVisible, true) writeFileSync(join(__dirname, '../dist/local/smoke-ready.png'), (await window.webContents.capturePage()).toPNG()) diff --git a/src/main/updateController.mjs b/src/main/updateController.mjs index 319a919..812eb13 100644 --- a/src/main/updateController.mjs +++ b/src/main/updateController.mjs @@ -7,7 +7,7 @@ export function createUpdateController({ updater, version, enabled, testing = fa let disposed = false let started = false const listeners = [] - updater.autoDownload = true + updater.autoDownload = false // Apply only after the user explicitly chooses Restart to update. updater.autoInstallOnAppQuit = false updater.allowPrerelease = false @@ -31,7 +31,7 @@ export function createUpdateController({ updater, version, enabled, testing = fa if (enabled) { listen('checking-for-update', () => publish({ status: 'checking', version: null, percent: 0 })) listen('update-not-available', () => publish({ status: 'current' })) - listen('update-available', (info) => publish({ status: 'downloading', version: info.version, percent: 0 })) + listen('update-available', (info) => publish({ status: 'available', version: info.version, percent: 0 })) listen('download-progress', (progress) => { const percent = Number.isFinite(progress.percent) ? Math.max(0, Math.min(100, Math.round(progress.percent))) : 0 publish({ status: 'downloading', percent }) @@ -41,13 +41,11 @@ export function createUpdateController({ updater, version, enabled, testing = fa } async function check() { - if (!enabled || disposed || busy || ['ready', 'installing'].includes(state.status)) return snapshot() + if (!enabled || disposed || busy || ['available', 'downloading', 'ready', 'installing'].includes(state.status)) return snapshot() busy = true publish({ status: 'checking', version: null, percent: 0 }) try { const result = await updater.checkForUpdates() - // Handle download failures too; checkForUpdates resolves before the download does. - if (result?.downloadPromise) await result.downloadPromise if (!result && state.status === 'checking') publish({ status: 'error' }) } catch { publish({ status: 'error' }) @@ -57,6 +55,21 @@ export function createUpdateController({ updater, version, enabled, testing = fa return snapshot() } + async function download() { + if (!enabled || disposed || busy || state.status !== 'available') return snapshot() + busy = true + publish({ status: 'downloading', percent: 0 }) + try { + await updater.downloadUpdate() + if (state.status === 'downloading') publish({ status: 'error' }) + } catch { + publish({ status: 'error' }) + } finally { + busy = false + } + return snapshot() + } + function start() { if (!enabled || disposed || started) return started = true @@ -89,5 +102,5 @@ export function createUpdateController({ updater, version, enabled, testing = fa for (const [event, handler] of listeners) updater.removeListener(event, handler) } - return { snapshot, check, install, start, dispose } + return { snapshot, check, download, install, start, dispose } } diff --git a/src/main/updateIpc.mjs b/src/main/updateIpc.mjs index 98b0651..68d557f 100644 --- a/src/main/updateIpc.mjs +++ b/src/main/updateIpc.mjs @@ -14,11 +14,12 @@ export function isTrustedUpdateSender(event, windows, rendererUrl) { } } -/** Register the three fixed update actions; no feed URL or command crosses IPC. */ +/** Register fixed update actions; no feed URL or command crosses IPC. */ export function registerUpdateIpc({ ipcMain, controller, getWindows, rendererUrl }) { const actions = { 'capsule:updates:status': () => controller.snapshot(), 'capsule:updates:check': () => controller.check(), + 'capsule:updates:download': () => controller.download(), 'capsule:updates:install': () => controller.install() } for (const [channel, action] of Object.entries(actions)) { diff --git a/src/preload/index.js b/src/preload/index.js index 8a5d1c5..d8f0d56 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -11,6 +11,11 @@ const capsule = { return ipcRenderer.invoke('capsule:updates:check') }, + /** Download the discovered update only after the user requests it. */ + downloadUpdate() { + return ipcRenderer.invoke('capsule:updates:download') + }, + /** Install an already verified download and restart Capsule. */ installUpdate() { return ipcRenderer.invoke('capsule:updates:install') diff --git a/src/renderer/index.html b/src/renderer/index.html index 29b3d2b..212d33d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -200,6 +200,7 @@ Loading update status… + @@ -285,11 +286,6 @@ -

- -

@@ -760,7 +756,7 @@