Compare commits

..

5 Commits

25 changed files with 264 additions and 76 deletions

3
.env.example Normal file
View File

@ -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

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ dist/
Thumbs.db Thumbs.db
.env .env
.env.* .env.*
!.env.example
*.pfx *.pfx
*.p12 *.p12
*.pem *.pem

View File

@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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.
- TTP Capsule plugin publishing integration with bounded chunk uploads, JSON-compatible YAML metadata, public artifact verification, and single-range update downloads. - TTP Capsule plugin publishing integration with bounded chunk uploads, JSON-compatible YAML metadata, public artifact verification, and single-range update downloads.
- Windows x64 NSIS packaging with separate unsigned Capsule Local builds and signed public release builds. Release builds require an HTTPS feed and publisher identity; sessions remain outside the installation directory. - Windows x64 NSIS packaging with separate unsigned Capsule Local builds and signed public release builds. Release builds require an HTTPS feed and publisher identity; sessions remain outside the installation directory.
- Main-process automatic update checks at startup and every four hours, background downloads, progress, manual checks, and explicit restart-to-install controls before and after sign-in. Duplicate checks are suppressed; failures remain retryable. Connected sites cannot choose an update feed or installer. - Main-process automatic update checks at startup and every four hours, background downloads, progress, manual checks, and explicit restart-to-install controls before and after sign-in. Duplicate checks are suppressed; failures remain retryable. Connected sites cannot choose an update feed or installer.
@ -22,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Local and test installers now display Capsule for the app, executable, and shortcuts, and use Capsule installer filenames. Existing local installation identity and session storage are preserved.
- 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 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.” - 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. - Profile page: space the white panel below the main nav, and cap the avatar at 200×200.

View File

@ -17,9 +17,13 @@ npm run dev
## Package and update ## Package and update
`npm run dist:win` builds a per-user Windows x64 installer at `dist/local/Capsule-Local-0.1.0-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. `npm run pack:win` produces an unpacked app for inspection. `npm test` checks updater behavior and release validation. 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.
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. 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-0.1.2-x64-Setup.exe` (the filename follows the package version). This unsigned local build installs as **Capsule**. It retains the previous Capsule Local installation identity and session directory for upgrades; automatic updates remain disabled. Close the existing app and run the new installer under the same Windows account to apply the rename. `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/`. 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. 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.

View File

@ -18,8 +18,13 @@ export function validateFeedUrl(value) {
} }
/** Return a Windows build config; public releases fail closed without signing. */ /** Return a Windows build config; public releases fail closed without signing. */
export function createBuildConfig({ release = false, env = process.env } = {}) { export function createBuildConfig({ release = false, testUpdates = false, bootstrap = false, env = process.env } = {}) {
const url = release ? validateFeedUrl(env.CAPSULE_UPDATE_URL) : null if (release && testUpdates) throw new Error('Test updates cannot be combined with a public release.')
if (bootstrap && !testUpdates) throw new Error('Bootstrap requires test updates.')
const url = testUpdates ? validateFeedUrl(env.CAPSULE_TEST_UPDATE_URL) : release ? validateFeedUrl(env.CAPSULE_UPDATE_URL) : null
if (testUpdates && !new URL(url).pathname.endsWith('/capsule/testfeed/')) {
throw new Error('Test updates require a separate /capsule/testfeed/ URL.')
}
const publisher = env.CAPSULE_PUBLISHER_NAME?.trim() const publisher = env.CAPSULE_PUBLISHER_NAME?.trim()
if (release && !publisher) { if (release && !publisher) {
throw new Error('CAPSULE_PUBLISHER_NAME must match the signing certificate subject CN.') throw new Error('CAPSULE_PUBLISHER_NAME must match the signing certificate subject CN.')
@ -29,9 +34,9 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
} }
return { return {
appId: release ? APP_ID : `${APP_ID}.local`, appId: release ? APP_ID : `${APP_ID}.local`,
productName: release ? 'Capsule' : 'Capsule Local', productName: 'Capsule',
executableName: release ? 'Capsule' : 'Capsule Local', executableName: 'Capsule',
directories: { output: release ? 'dist/release' : 'dist/local' }, directories: { output: release ? 'dist/release' : testUpdates ? bootstrap ? 'dist/test-bootstrap' : 'dist/test' : 'dist/local' },
files: ['out/**/*', 'package.json'], files: ['out/**/*', 'package.json'],
asar: true, asar: true,
npmRebuild: false, npmRebuild: false,
@ -39,12 +44,14 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
extraMetadata: { extraMetadata: {
// Keep the release userData path compatible with the original dev app. // Keep the release userData path compatible with the original dev app.
name: release ? 'capsule' : 'capsule-local', name: release ? 'capsule' : 'capsule-local',
capsuleUpdates: { enabled: release } ...(bootstrap ? { version: '0.1.0' } : {}),
capsuleUpdates: { enabled: release || testUpdates, testing: testUpdates }
}, },
artifactName: release ? 'Capsule-${version}-${arch}-Setup.${ext}' : 'Capsule-Local-${version}-${arch}-Setup.${ext}', artifactName: 'Capsule-${version}-${arch}-Setup.${ext}',
win: { win: {
icon: 'build/icon.ico',
target: [{ target: 'nsis', arch: ['x64'] }], target: [{ target: 'nsis', arch: ['x64'] }],
verifyUpdateCodeSignature: true, verifyUpdateCodeSignature: !testUpdates,
signExecutable: release, signExecutable: release,
...(release ? { signtoolOptions: { ...(release ? { signtoolOptions: {
publisherName: publisher, publisherName: publisher,
@ -58,8 +65,8 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
allowElevation: false, allowElevation: false,
deleteAppDataOnUninstall: false, deleteAppDataOnUninstall: false,
runAfterFinish: false, runAfterFinish: false,
shortcutName: release ? 'Capsule' : 'Capsule Local' shortcutName: 'Capsule'
}, },
publish: release ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null publish: release || testUpdates ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null
} }
} }

8
build/defaultSite.mjs Normal file
View File

@ -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(/\/+$/, '')
}

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -6,9 +6,31 @@ Capsule uses electron-builder 26.15.3 and electron-updater 6.8.9. The first dist
Run `npm run dist:win`. The installer and blockmap are written to `dist/local/`; `npm run pack:win` produces only `dist/local/win-unpacked/`. Run `npm run dist:win`. The installer and blockmap are written to `dist/local/`; `npm run pack:win` produces only `dist/local/win-unpacked/`.
Local builds are unsigned, named **Capsule Local**, and have application ID `com.thetempusproject.capsule.local`. They store sessions in `%APPDATA%/capsule-local`. Updates are deliberately disabled, and these installers must not be distributed as the public product. Windows may display an unknown-publisher warning. Local builds are unsigned, named **Capsule** (formerly Capsule Local), and have application ID `com.thetempusproject.capsule.local`. The executable and shortcuts are named Capsule, and installer filenames use `Capsule-${version}-${arch}-Setup.exe`. The internal package name remains `capsule-local` so existing installations retain their identity and data. They store sessions in `%APPDATA%/capsule-local`. Updates are deliberately disabled, and these installers must not be distributed as the public product. Windows may display an unknown-publisher warning.
Public builds use the permanent ID `com.thetempusproject.capsule`, executable `Capsule.exe`, and `%APPDATA%/capsule/session.json`, preserving the original app's session location. Both installers run per user and preserve app data on uninstall. Do not change the public ID, package name, install scope, or data path after shipping without an explicit migration. The current installer uses Electron's default icon; add an approved `build/icon.ico` before public branding is finalized. Public builds use the permanent ID `com.thetempusproject.capsule`, executable `Capsule.exe`, and `%APPDATA%/capsule/session.json`, preserving the original app's session location. Both installers run per user and preserve app data on uninstall. Do not change the public ID, package name, install scope, or data path after shipping without an explicit migration. Windows builds use `build/icon.ico`, which must contain a 256×256 image. A packaged update replaces the application icon without uninstalling; Windows may temporarily retain a cached shortcut or taskbar icon.
## Free automatic-update testing
The original Capsule Local 0.1.0 installer cannot check for updates. Install the update-enabled bootstrap once to test an update to the current version:
```powershell
$env:CAPSULE_TEST_UPDATE_URL = 'https://ttp.joeykimsey.com/capsule/testfeed/'
npm run dist:test-bootstrap
npm run dist:test
```
`dist/test-bootstrap/Capsule-0.1.0-x64-Setup.exe` installs as Capsule and preserves its existing session location. Close the existing app before installing it. `dist/test/Capsule-0.1.2-x64-Setup.exe` is the current update target; do not manually install that target when testing the updater. Both builds use the same Local identity. The dashboard message is hidden in the bootstrap and shows the installed version after updating.
Test builds explicitly omit Authenticode verification; they rely on HTTPS, the publisher-controlled test feed, and metadata checksums. This is for trusted testers, not public signed releases. Windows can warn or block unsigned executables. The app labels this mode “Unsigned test channel.” Public release builds still require signing and verify publisher signatures. Normal `dist:win` builds still have updates disabled.
The TTP plugin must include `/capsule/testfeed/` and `/capsule/testupload/` support before publication. Those endpoints use separate storage from the public release channel, with the existing dedicated publisher token. After the operator deploys that code, set `CAPSULE_UPLOAD_URL` to `https://ttp.joeykimsey.com/capsule/testupload/` and load `CAPSULE_UPLOAD_TOKEN` securely, then run:
```powershell
npm run publish:release -- --test-updates --upload
```
The command publishes only `dist/test`, with metadata last. It never publishes the bootstrap or changes the public feed. Open the installed bootstrap, choose **Check for updates**, wait for **Restart to update**, then verify version 0.1.2, the custom icon, the dashboard message, and the preserved connection. Signed production upgrade acceptance remains a separate test.
## 2. Configure the release feed ## 2. Configure the release feed
@ -28,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. `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 ## 4. Sign a public release
@ -40,7 +62,7 @@ Set these variables in a secure release environment:
- `CAPSULE_PUBLISHER_NAME`: the exact common name on the signing certificate. - `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. - `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. 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.

View File

@ -1,7 +1,14 @@
import { defineConfig } from 'electron-vite' import { defineConfig } from 'electron-vite'
import { loadEnv } from 'vite'
import { defaultSite } from './build/defaultSite.mjs'
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), 'CAPSULE_')
return {
main: {}, main: {},
preload: {}, preload: {},
renderer: {} renderer: {
define: { __CAPSULE_DEFAULT_SITE__: JSON.stringify(defaultSite(process.env.CAPSULE_DEFAULT_SITE ?? env.CAPSULE_DEFAULT_SITE)) }
}
}
}) })

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "capsule", "name": "capsule",
"version": "0.1.0", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "capsule", "name": "capsule",
"version": "0.1.0", "version": "0.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "capsule", "name": "capsule",
"version": "0.1.0", "version": "0.1.2",
"description": "Desktop companion for The Tempus Project sites", "description": "Desktop companion for The Tempus Project sites",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Joey Kimsey <Joey@thetempusproject.com>", "author": "Joey Kimsey <Joey@thetempusproject.com>",
@ -14,6 +14,8 @@
"smoke": "electron scripts/smoke.cjs", "smoke": "electron scripts/smoke.cjs",
"pack:win": "npm run build && node scripts/package.mjs --dir", "pack:win": "npm run build && node scripts/package.mjs --dir",
"dist:win": "npm run build && node scripts/package.mjs", "dist:win": "npm run build && node scripts/package.mjs",
"dist:test": "npm run build && node scripts/package.mjs --test-updates",
"dist:test-bootstrap": "npm run build && node scripts/package.mjs --test-updates --bootstrap",
"prerelease:win": "node scripts/invalidate-release.mjs", "prerelease:win": "node scripts/invalidate-release.mjs",
"release:win": "npm test && npm run build && node scripts/package.mjs --release", "release:win": "npm test && npm run build && node scripts/package.mjs --release",
"publish:release": "node scripts/publish.mjs" "publish:release": "node scripts/publish.mjs"

View File

@ -4,10 +4,13 @@ import { createBuildConfig } from '../build/config.mjs'
import { writeReleaseManifest } from './releaseArtifacts.mjs' import { writeReleaseManifest } from './releaseArtifacts.mjs'
const args = process.argv.slice(2) const args = process.argv.slice(2)
if (args.some((arg) => !['--release', '--dir'].includes(arg))) { if (args.some((arg) => !['--release', '--dir', '--test-updates', '--bootstrap'].includes(arg))) {
throw new Error('Usage: node scripts/package.mjs [--release] [--dir]') throw new Error('Usage: node scripts/package.mjs [--release | --test-updates [--bootstrap]] [--dir]')
} }
const release = args.includes('--release') const release = args.includes('--release')
const testUpdates = args.includes('--test-updates')
const bootstrap = args.includes('--bootstrap')
if (testUpdates && args.includes('--dir')) throw new Error('Test update builds must produce an installer.')
if (release && args.includes('--dir')) throw new Error('Release builds must produce an installer.') if (release && args.includes('--dir')) throw new Error('Release builds must produce an installer.')
// Never let a failed rebuild leave an older directory looking publishable. // Never let a failed rebuild leave an older directory looking publishable.
if (release) { if (release) {
@ -15,12 +18,13 @@ if (release) {
} else { } else {
process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false' process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
} }
const config = createBuildConfig({ release }) const config = createBuildConfig({ release, testUpdates, bootstrap })
if (testUpdates) await rm(`${config.directories.output}/release-ready.json`, { force: true })
await build({ await build({
config, config,
targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64), targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64),
publish: 'never' publish: 'never'
}) })
if (release) { if (release || testUpdates) {
await writeReleaseManifest(config.directories.output, config.publish[0].url) await writeReleaseManifest(config.directories.output, config.publish[0].url)
} }

View File

@ -2,8 +2,13 @@ import { verifyRelease } from './releaseArtifacts.mjs'
import { uploadRelease } from './uploadRelease.mjs' import { uploadRelease } from './uploadRelease.mjs'
const args = process.argv.slice(2) const args = process.argv.slice(2)
if (args.some((arg) => arg !== '--upload')) throw new Error('Usage: npm run publish:release -- [--upload]') if (args.some((arg) => !['--upload', '--test-updates'].includes(arg))) throw new Error('Usage: npm run publish:release -- [--test-updates] [--upload]')
const release = await verifyRelease('dist/release') const testing = args.includes('--test-updates')
const release = await verifyRelease(testing ? 'dist/test' : 'dist/release')
if (testing && (!new URL(release.feedUrl).pathname.endsWith('/capsule/testfeed/') ||
(args.includes('--upload') && !new URL(process.env.CAPSULE_UPLOAD_URL).pathname.endsWith('/capsule/testupload/')))) {
throw new Error('Test publication requires the separate testfeed and testupload endpoints.')
}
console.log(`Capsule ${release.version}: ${release.feedUrl}`) console.log(`Capsule ${release.version}: ${release.feedUrl}`)
if (!args.includes('--upload')) { if (!args.includes('--upload')) {
console.log('Verified. Upload order:') console.log('Verified. Upload order:')

View File

@ -37,15 +37,44 @@ app.on('browser-window-created', (_event, window) => {
assert.equal(state.restartHidden, true) assert.equal(state.restartHidden, true)
assert.match(state.label, /automatic updates are disabled/) assert.match(state.label, /automatic updates are disabled/)
assert.equal(app.getPath('userData'), join(profile, 'capsule-local')) 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() const image = await window.webContents.capturePage()
writeFileSync(join(__dirname, '../dist/local/smoke.png'), image.toPNG()) 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. // Exercise the ready prompt with synthetic state; never invoke installation.
window.webContents.send('capsule:updates:changed', { window.webContents.send('capsule:updates:changed', {
status: 'ready', currentVersion: app.getVersion(), version: '0.1.1', percent: 100 status: 'ready', currentVersion: app.getVersion(), version: '0.1.1', percent: 100
}) })
const readyVisible = await window.webContents.executeJavaScript(`new Promise(resolve => setTimeout(() => { const readyVisible = await window.webContents.executeJavaScript(`new Promise(resolve => setTimeout(() => {
const button = document.getElementById('update-install') 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))`) }, 100))`)
assert.equal(readyVisible, true) assert.equal(readyVisible, true)
writeFileSync(join(__dirname, '../dist/local/smoke-ready.png'), (await window.webContents.capturePage()).toPNG()) writeFileSync(join(__dirname, '../dist/local/smoke-ready.png'), (await window.webContents.capturePage()).toPNG())

View File

@ -1,13 +1,13 @@
/** Own update state and retries without exposing updater options to the renderer. */ /** Own update state and retries without exposing updater options to the renderer. */
export function createUpdateController({ updater, version, enabled, onState = () => {}, export function createUpdateController({ updater, version, enabled, testing = false, onState = () => {},
schedule = setTimeout, cancel = clearTimeout, firstDelay = 15000, interval = 4 * 60 * 60 * 1000 }) { schedule = setTimeout, cancel = clearTimeout, firstDelay = 15000, interval = 4 * 60 * 60 * 1000 }) {
let state = { status: enabled ? 'idle' : 'disabled', currentVersion: version, version: null, percent: 0 } let state = { status: enabled ? 'idle' : 'disabled', currentVersion: version, version: null, percent: 0, testing }
let timer let timer
let busy = false let busy = false
let disposed = false let disposed = false
let started = false let started = false
const listeners = [] const listeners = []
updater.autoDownload = true updater.autoDownload = false
// Apply only after the user explicitly chooses Restart to update. // Apply only after the user explicitly chooses Restart to update.
updater.autoInstallOnAppQuit = false updater.autoInstallOnAppQuit = false
updater.allowPrerelease = false updater.allowPrerelease = false
@ -31,7 +31,7 @@ export function createUpdateController({ updater, version, enabled, onState = ()
if (enabled) { if (enabled) {
listen('checking-for-update', () => publish({ status: 'checking', version: null, percent: 0 })) listen('checking-for-update', () => publish({ status: 'checking', version: null, percent: 0 }))
listen('update-not-available', () => publish({ status: 'current' })) 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) => { listen('download-progress', (progress) => {
const percent = Number.isFinite(progress.percent) ? Math.max(0, Math.min(100, Math.round(progress.percent))) : 0 const percent = Number.isFinite(progress.percent) ? Math.max(0, Math.min(100, Math.round(progress.percent))) : 0
publish({ status: 'downloading', percent }) publish({ status: 'downloading', percent })
@ -41,13 +41,11 @@ export function createUpdateController({ updater, version, enabled, onState = ()
} }
async function check() { 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 busy = true
publish({ status: 'checking', version: null, percent: 0 }) publish({ status: 'checking', version: null, percent: 0 })
try { try {
const result = await updater.checkForUpdates() 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' }) if (!result && state.status === 'checking') publish({ status: 'error' })
} catch { } catch {
publish({ status: 'error' }) publish({ status: 'error' })
@ -57,6 +55,21 @@ export function createUpdateController({ updater, version, enabled, onState = ()
return snapshot() 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() { function start() {
if (!enabled || disposed || started) return if (!enabled || disposed || started) return
started = true started = true
@ -89,5 +102,5 @@ export function createUpdateController({ updater, version, enabled, onState = ()
for (const [event, handler] of listeners) updater.removeListener(event, handler) for (const [event, handler] of listeners) updater.removeListener(event, handler)
} }
return { snapshot, check, install, start, dispose } return { snapshot, check, download, install, start, dispose }
} }

View File

@ -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 }) { export function registerUpdateIpc({ ipcMain, controller, getWindows, rendererUrl }) {
const actions = { const actions = {
'capsule:updates:status': () => controller.snapshot(), 'capsule:updates:status': () => controller.snapshot(),
'capsule:updates:check': () => controller.check(), 'capsule:updates:check': () => controller.check(),
'capsule:updates:download': () => controller.download(),
'capsule:updates:install': () => controller.install() 'capsule:updates:install': () => controller.install()
} }
for (const [channel, action] of Object.entries(actions)) { for (const [channel, action] of Object.entries(actions)) {

View File

@ -8,15 +8,18 @@ import { registerUpdateIpc, isTrustedUpdateSender } from './updateIpc.mjs'
/** Start updates independently of site sign-in, using only packaged release config. */ /** Start updates independently of site sign-in, using only packaged release config. */
export function registerUpdates(rendererUrl) { export function registerUpdates(rendererUrl) {
let enabled = false let enabled = false
let testing = false
if (app.isPackaged && process.platform === 'win32') { if (app.isPackaged && process.platform === 'win32') {
const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8')) const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8'))
enabled = metadata.capsuleUpdates?.enabled === true enabled = metadata.capsuleUpdates?.enabled === true
testing = metadata.capsuleUpdates?.testing === true && metadata.name === 'capsule-local'
} }
const { autoUpdater } = electronUpdater const { autoUpdater } = electronUpdater
const controller = createUpdateController({ const controller = createUpdateController({
updater: autoUpdater, updater: autoUpdater,
version: app.getVersion(), version: app.getVersion(),
enabled, enabled,
testing,
onState(state) { onState(state) {
const windows = BrowserWindow.getAllWindows() const windows = BrowserWindow.getAllWindows()
for (const window of windows) { for (const window of windows) {

View File

@ -11,6 +11,11 @@ const capsule = {
return ipcRenderer.invoke('capsule:updates:check') 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. */ /** Install an already verified download and restart Capsule. */
installUpdate() { installUpdate() {
return ipcRenderer.invoke('capsule:updates:install') return ipcRenderer.invoke('capsule:updates:install')

View File

@ -200,6 +200,7 @@
<span id="update-status" role="status" aria-live="polite">Loading update status…</span> <span id="update-status" role="status" aria-live="polite">Loading update status…</span>
<progress id="update-progress" max="100" value="0" aria-label="Update download" hidden></progress> <progress id="update-progress" max="100" value="0" aria-label="Update download" hidden></progress>
<button id="update-check" type="button" class="btn btn-sm btn-outline-secondary">Check for updates</button> <button id="update-check" type="button" class="btn btn-sm btn-outline-secondary">Check for updates</button>
<button id="update-download" type="button" class="btn btn-sm btn-primary" hidden>Download update</button>
<button id="update-install" type="button" class="btn btn-sm btn-primary" hidden>Restart to update</button> <button id="update-install" type="button" class="btn btn-sm btn-primary" hidden>Restart to update</button>
</aside> </aside>
@ -285,11 +286,6 @@
</button> </button>
</form> </form>
</details> </details>
<p class="text-center mt-4 mb-0">
<button id="preview-shell" type="button" class="btn btn-link">
Preview the desktop chrome
</button>
</p>
</div> </div>
</div> </div>
</section> </section>
@ -352,6 +348,7 @@
<div class="m-2 m-lg-4"> <div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg"> <div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Dashboard</h1> <h1 class="h3">Dashboard</h1>
<p class="alert alert-success" id="home-update-notice" hidden>you have updated to 0.1.1</p>
<p class="text-muted" id="home-note"></p> <p class="text-muted" id="home-note"></p>
<p class="mb-0"> <p class="mb-0">
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a> Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
@ -759,7 +756,7 @@
<div class="collapse d-md-block my-4" id="footerMenu"> <div class="collapse d-md-block my-4" id="footerMenu">
<div class="capsule-footer-cols"> <div class="capsule-footer-cols">
<div id="footer-contact-col" class="capsule-footer-contact" hidden> <div id="footer-contact-col" class="capsule-footer-contact invisible">
<h2 class="h5">Contact Us</h2> <h2 class="h5">Contact Us</h2>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li id="footer-link-contact" class="nav-item mb-2" hidden> <li id="footer-link-contact" class="nav-item mb-2" hidden>

View File

@ -8,7 +8,6 @@ import {
demoNotifications, demoNotifications,
demoProfile, demoProfile,
pageLimitOptions, pageLimitOptions,
previewSession,
timeFormatOptions, timeFormatOptions,
timezoneOptions timezoneOptions
} from './demo.js' } from './demo.js'
@ -45,7 +44,7 @@ const tokenError = document.getElementById('token-error')
const tokenSubmit = document.getElementById('token-submit') const tokenSubmit = document.getElementById('token-submit')
const tokenSite = document.getElementById('token-site') const tokenSite = document.getElementById('token-site')
const tokenUsername = document.getElementById('token-username') const tokenUsername = document.getElementById('token-username')
const previewButton = document.getElementById('preview-shell') const defaultSiteUrl = __CAPSULE_DEFAULT_SITE__
let session = null let session = null
let profile = { ...demoProfile } let profile = { ...demoProfile }
@ -336,10 +335,8 @@ function clearPendingAvatar() {
* @return {string} - image URL * @return {string} - image URL
*/ */
function logoUrl() { function logoUrl() {
if (!session?.siteUrl) { const root = session?.siteUrl || session?.lastSiteUrl || defaultSiteUrl
return '' return `${root.replace(/\/+$/, '')}/images/logoWhite.png`
}
return `${session.siteUrl.replace(/\/+$/, '')}/images/logoWhite.png`
} }
/** /**
@ -593,7 +590,7 @@ function renderFooter() {
const bugOn = featureInMenu('bugreport') const bugOn = featureInMenu('bugreport')
document.getElementById('footer-link-contact').hidden = !contactOn document.getElementById('footer-link-contact').hidden = !contactOn
document.getElementById('footer-link-bug').hidden = !bugOn document.getElementById('footer-link-bug').hidden = !bugOn
document.getElementById('footer-contact-col').hidden = !contactOn && !bugOn document.getElementById('footer-contact-col').classList.toggle('invisible', !contactOn && !bugOn)
} }
/** /**
@ -1074,9 +1071,10 @@ function applySession(next) {
} else if (!window.location.hash) { } else if (!window.location.hash) {
window.location.hash = '#/' window.location.hash = '#/'
} }
if (loginSite && session?.lastSiteUrl) { if (loginSite) {
loginSite.value = session.lastSiteUrl const site = session?.lastSiteUrl || session?.siteUrl || defaultSiteUrl
tokenSite.value = session.lastSiteUrl loginSite.value = site
tokenSite.value = site
} }
if (session?.pendingMfa) { if (session?.pendingMfa) {
document.getElementById('login-password').value = '' document.getElementById('login-password').value = ''
@ -1352,10 +1350,6 @@ tokenForm.addEventListener('submit', (event) => {
) )
}) })
previewButton.addEventListener('click', () => {
applySession({ ...previewSession })
})
document.getElementById('logout-button').addEventListener('click', async () => { document.getElementById('logout-button').addEventListener('click', async () => {
if (session?.preview || !window.capsule) { if (session?.preview || !window.capsule) {
applySession({ connected: false, lastSiteUrl: session?.siteUrl || '' }) applySession({ connected: false, lastSiteUrl: session?.siteUrl || '' })

View File

@ -125,12 +125,6 @@ footer a.context-main {
grid-area: contact; grid-area: contact;
} }
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-areas:
'theme'
'info';
}
.capsule-footer-theme { .capsule-footer-theme {
grid-area: theme; grid-area: theme;
} }
@ -158,10 +152,6 @@ footer a.context-main {
text-align: end; text-align: end;
} }
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'theme info';
}
} }
/** /**
@ -471,6 +461,13 @@ header .form-select:focus {
font-size: 0.875rem; font-size: 0.875rem;
} }
/** Keep update actions distinct from the neutral version status strip. */
.capsule-updates.is-update:not([hidden]) {
background: var(--bs-info-bg-subtle, #cff4fc);
color: var(--bs-info-text-emphasis, #055160);
border-color: var(--bs-info-border-subtle, #9eeaf9);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.capsule-header { .capsule-header {
grid-template-columns: auto auto; grid-template-columns: auto auto;

View File

@ -1,6 +1,7 @@
const panel = document.getElementById('capsule-updates') const panel = document.getElementById('capsule-updates')
const label = document.getElementById('update-status') const label = document.getElementById('update-status')
const check = document.getElementById('update-check') const check = document.getElementById('update-check')
const download = document.getElementById('update-download')
const install = document.getElementById('update-install') const install = document.getElementById('update-install')
const progress = document.getElementById('update-progress') const progress = document.getElementById('update-progress')
@ -8,7 +9,8 @@ const progress = document.getElementById('update-progress')
function renderUpdate(state) { function renderUpdate(state) {
const messages = { const messages = {
disabled: 'Local build — automatic updates are disabled.', disabled: 'Local build — automatic updates are disabled.',
idle: 'Updates download automatically. You choose when to restart.', idle: 'Updates are checked automatically. You choose when to download and restart.',
available: `Capsule ${state.version} is available. Download the update when you are ready.`,
checking: 'Checking for updates…', checking: 'Checking for updates…',
current: 'Capsule is up to date.', current: 'Capsule is up to date.',
downloading: `Downloading Capsule ${state.version}${state.percent}%`, downloading: `Downloading Capsule ${state.version}${state.percent}%`,
@ -16,8 +18,14 @@ function renderUpdate(state) {
installing: 'Restarting to install the update…', installing: 'Restarting to install the update…',
error: 'Could not update Capsule. You can keep working and try again.' error: 'Could not update Capsule. You can keep working and try again.'
} }
label.textContent = `Capsule ${state.currentVersion} · ${messages[state.status] || messages.error}` label.textContent = `Capsule ${state.currentVersion}${state.testing ? ' · Unsigned test channel' : ''} · ${messages[state.status] || messages.error}`
check.hidden = state.status === 'disabled' panel.classList.toggle('is-update', ['available', 'downloading', 'ready', 'installing'].includes(state.status))
const notice = document.getElementById('home-update-notice')
notice.hidden = state.currentVersion === '0.1.0'
notice.textContent = `you have updated to ${state.currentVersion}`
check.hidden = ['disabled', 'available', 'downloading', 'ready', 'installing'].includes(state.status)
download.hidden = state.status !== 'available'
download.disabled = state.status !== 'available'
check.disabled = ['checking', 'downloading', 'ready', 'installing'].includes(state.status) check.disabled = ['checking', 'downloading', 'ready', 'installing'].includes(state.status)
install.hidden = state.status !== 'ready' install.hidden = state.status !== 'ready'
progress.hidden = state.status !== 'downloading' progress.hidden = state.status !== 'downloading'
@ -41,6 +49,15 @@ if (window.capsule?.updateStatus) {
check.disabled = false check.disabled = false
} }
}) })
download.addEventListener('click', async () => {
download.disabled = true
try {
renderUpdate(await window.capsule.downloadUpdate())
} catch {
label.textContent = 'Could not download the update. Try again.'
download.disabled = false
}
})
install.addEventListener('click', async () => { install.addEventListener('click', async () => {
install.disabled = true install.disabled = true
try { try {

View File

@ -0,0 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { defaultSite } from '../build/defaultSite.mjs'
test('default site supports the demo and configurable site paths without embedding credentials', () => {
assert.equal(defaultSite(), 'https://ttp.joeykimsey.com')
assert.equal(defaultSite(' https://site.test/ttp/ '), 'https://site.test/ttp')
assert.equal(defaultSite('http://192.168.1.194:8024/'), 'http://192.168.1.194:8024')
for (const value of ['', 'not a url', 'file:///C:/test', 'https://user:secret@site.test/', 'https://site.test/?token=secret', 'https://site.test/#login']) {
assert.throws(() => defaultSite(value))
}
})

View File

@ -37,6 +37,28 @@ test('local installer has separate identity, no feed, and cannot wipe app data',
assert.equal(config.nsis.deleteAppDataOnUninstall, false) assert.equal(config.nsis.deleteAppDataOnUninstall, false)
}) })
test('unsigned updates require explicit isolated test mode and retain the Local install identity', () => {
const testEnv = { CAPSULE_TEST_UPDATE_URL: 'https://ttp.joeykimsey.com/capsule/testfeed/' }
assert.throws(() => createBuildConfig({ testUpdates: true, env: {} }), /HTTPS/)
assert.throws(() => createBuildConfig({ release: true, testUpdates: true, env: testEnv }), /combined/)
assert.throws(() => createBuildConfig({ bootstrap: true }), /Bootstrap/)
assert.throws(() => createBuildConfig({ testUpdates: true, env: { CAPSULE_TEST_UPDATE_URL: feed } }), /separate/)
const local = createBuildConfig({ env: {} })
const testBuild = createBuildConfig({ testUpdates: true, env: testEnv })
assert.equal(testBuild.appId, local.appId)
assert.equal(testBuild.extraMetadata.name, local.extraMetadata.name)
assert.equal(testBuild.extraMetadata.capsuleUpdates.enabled, true)
assert.equal(testBuild.extraMetadata.capsuleUpdates.testing, true)
assert.equal(testBuild.win.verifyUpdateCodeSignature, false)
assert.equal(testBuild.forceCodeSigning, false)
assert.equal(testBuild.publish[0].url, testEnv.CAPSULE_TEST_UPDATE_URL)
assert.equal(testBuild.nsis.deleteAppDataOnUninstall, false)
const bootstrap = createBuildConfig({ testUpdates: true, bootstrap: true, env: testEnv })
assert.equal(bootstrap.extraMetadata.version, '0.1.0')
assert.notEqual(bootstrap.directories.output, testBuild.directories.output)
assert.equal(createBuildConfig({ release: true, env }).win.verifyUpdateCodeSignature, true)
})
test('release verification detects tampering and publishes metadata last', async (t) => { test('release verification detects tampering and publishes metadata last', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'capsule-release-test-')) const directory = await mkdtemp(join(tmpdir(), 'capsule-release-test-'))
t.after(() => rm(directory, { recursive: true, force: true })) t.after(() => rm(directory, { recursive: true, force: true }))

View File

@ -10,6 +10,7 @@ function fixture(enabled = true) {
const scheduled = [] const scheduled = []
const cancelled = [] const cancelled = []
updater.checks = 0 updater.checks = 0
updater.downloads = 0
updater.installs = [] updater.installs = []
updater.checkForUpdates = async () => { updater.checkForUpdates = async () => {
updater.checks++ updater.checks++
@ -17,6 +18,10 @@ function fixture(enabled = true) {
return {} return {}
} }
updater.quitAndInstall = (...args) => updater.installs.push(args) updater.quitAndInstall = (...args) => updater.installs.push(args)
updater.downloadUpdate = async () => {
updater.downloads++
updater.emit('update-downloaded', { version: '0.1.1' })
}
const controller = createUpdateController({ const controller = createUpdateController({
updater, enabled, version: '0.1.0', onState: (state) => states.push(state), updater, enabled, version: '0.1.0', onState: (state) => states.push(state),
schedule: (callback, delay) => { const task = { callback, delay }; scheduled.push(task); return task }, schedule: (callback, delay) => { const task = { callback, delay }; scheduled.push(task); return task },
@ -29,8 +34,10 @@ test('local/dev builds never check, schedule, or install', async () => {
const { controller, updater, scheduled } = fixture(false) const { controller, updater, scheduled } = fixture(false)
controller.start() controller.start()
await controller.check() await controller.check()
await controller.download()
assert.equal(controller.snapshot().status, 'disabled') assert.equal(controller.snapshot().status, 'disabled')
assert.equal(updater.checks, 0) assert.equal(updater.checks, 0)
assert.equal(updater.downloads, 0)
assert.equal(scheduled.length, 0) assert.equal(scheduled.length, 0)
assert.equal(controller.install(), false) assert.equal(controller.install(), false)
}) })
@ -56,8 +63,13 @@ test('network and download failures are retryable without exposing raw errors',
await controller.check() await controller.check()
assert.equal(controller.snapshot().status, 'error') assert.equal(controller.snapshot().status, 'error')
assert.doesNotMatch(JSON.stringify(controller.snapshot()), /private/) assert.doesNotMatch(JSON.stringify(controller.snapshot()), /private/)
updater.checkForUpdates = async () => ({ downloadPromise: Promise.reject(new Error('checksum mismatch')) }) updater.checkForUpdates = async () => {
updater.emit('update-available', { version: '0.1.1' })
return {}
}
await controller.check() await controller.check()
updater.downloadUpdate = async () => { throw new Error('checksum mismatch') }
await controller.download()
assert.equal(controller.snapshot().status, 'error') assert.equal(controller.snapshot().status, 'error')
updater.checkForUpdates = async () => { updater.checkForUpdates = async () => {
updater.emit('update-not-available') updater.emit('update-not-available')
@ -73,12 +85,25 @@ test('one in-flight download, progress, verified readiness, and explicit restart
updater.checkForUpdates = async () => { updater.checkForUpdates = async () => {
updater.checks++ updater.checks++
updater.emit('update-available', { version: '0.1.1' }) updater.emit('update-available', { version: '0.1.1' })
return { downloadPromise: new Promise((resolve) => { finish = resolve }) } return {}
}
updater.downloadUpdate = async () => {
updater.downloads++
return new Promise((resolve) => { finish = resolve })
} }
assert.equal(controller.install(), false) assert.equal(controller.install(), false)
const pending = controller.check() await controller.download()
assert.equal(updater.downloads, 0)
await controller.check()
assert.equal(controller.snapshot().status, 'available')
assert.equal(updater.autoDownload, false)
assert.equal(updater.downloads, 0)
assert.equal(controller.install(), false)
const pending = controller.download()
await controller.download()
await controller.check() await controller.check()
assert.equal(updater.checks, 1) assert.equal(updater.checks, 1)
assert.equal(updater.downloads, 1)
updater.emit('download-progress', { percent: 53.2 }) updater.emit('download-progress', { percent: 53.2 })
assert.equal(controller.snapshot().percent, 53) assert.equal(controller.snapshot().percent, 53)
assert.equal(controller.install(), false) assert.equal(controller.install(), false)
@ -125,6 +150,7 @@ test('update IPC rejects sites, child frames, and unowned windows', () => {
controller, getWindows: () => windows, rendererUrl: url controller, getWindows: () => windows, rendererUrl: url
}) })
assert.throws(() => handlers.get('capsule:updates:install')(event), /denied/) assert.throws(() => handlers.get('capsule:updates:install')(event), /denied/)
assert.throws(() => handlers.get('capsule:updates:download')(event), /denied/)
frame.url = url frame.url = url
assert.equal(handlers.get('capsule:updates:status')(event).currentVersion, '0.1.0') assert.equal(handlers.get('capsule:updates:status')(event).currentVersion, '0.1.0')
cleanup() cleanup()