various bugfixes

This commit is contained in:
Joey Kimsey
2026-09-12 22:47:47 -04:00
parent 2eb64aa186
commit 2248aa14e5
17 changed files with 162 additions and 51 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
.env
.env.*
!.env.example
*.pfx
*.p12
*.pem

View File

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

View File

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

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

View File

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

View File

@ -1,7 +1,14 @@
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: {},
preload: {},
renderer: {}
renderer: {
define: { __CAPSULE_DEFAULT_SITE__: JSON.stringify(defaultSite(process.env.CAPSULE_DEFAULT_SITE ?? env.CAPSULE_DEFAULT_SITE)) }
}
}
})

View File

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

View File

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

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 }) {
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)) {

View File

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

View File

@ -200,6 +200,7 @@
<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>
<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>
</aside>
@ -285,11 +286,6 @@
</button>
</form>
</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>
</section>
@ -760,7 +756,7 @@
<div class="collapse d-md-block my-4" id="footerMenu">
<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>
<ul class="nav flex-column">
<li id="footer-link-contact" class="nav-item mb-2" hidden>

View File

@ -8,7 +8,6 @@ import {
demoNotifications,
demoProfile,
pageLimitOptions,
previewSession,
timeFormatOptions,
timezoneOptions
} from './demo.js'
@ -45,7 +44,7 @@ const tokenError = document.getElementById('token-error')
const tokenSubmit = document.getElementById('token-submit')
const tokenSite = document.getElementById('token-site')
const tokenUsername = document.getElementById('token-username')
const previewButton = document.getElementById('preview-shell')
const defaultSiteUrl = __CAPSULE_DEFAULT_SITE__
let session = null
let profile = { ...demoProfile }
@ -336,10 +335,8 @@ function clearPendingAvatar() {
* @return {string} - image URL
*/
function logoUrl() {
if (!session?.siteUrl) {
return ''
}
return `${session.siteUrl.replace(/\/+$/, '')}/images/logoWhite.png`
const root = session?.siteUrl || session?.lastSiteUrl || defaultSiteUrl
return `${root.replace(/\/+$/, '')}/images/logoWhite.png`
}
/**
@ -593,7 +590,7 @@ function renderFooter() {
const bugOn = featureInMenu('bugreport')
document.getElementById('footer-link-contact').hidden = !contactOn
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) {
window.location.hash = '#/'
}
if (loginSite && session?.lastSiteUrl) {
loginSite.value = session.lastSiteUrl
tokenSite.value = session.lastSiteUrl
if (loginSite) {
const site = session?.lastSiteUrl || session?.siteUrl || defaultSiteUrl
loginSite.value = site
tokenSite.value = site
}
if (session?.pendingMfa) {
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 () => {
if (session?.preview || !window.capsule) {
applySession({ connected: false, lastSiteUrl: session?.siteUrl || '' })

View File

@ -125,12 +125,6 @@ footer a.context-main {
grid-area: contact;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-areas:
'theme'
'info';
}
.capsule-footer-theme {
grid-area: theme;
}
@ -158,10 +152,6 @@ footer a.context-main {
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;
}
/** 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) {
.capsule-header {
grid-template-columns: auto auto;

View File

@ -1,6 +1,7 @@
const panel = document.getElementById('capsule-updates')
const label = document.getElementById('update-status')
const check = document.getElementById('update-check')
const download = document.getElementById('update-download')
const install = document.getElementById('update-install')
const progress = document.getElementById('update-progress')
@ -8,7 +9,8 @@ const progress = document.getElementById('update-progress')
function renderUpdate(state) {
const messages = {
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…',
current: 'Capsule is up to date.',
downloading: `Downloading Capsule ${state.version}${state.percent}%`,
@ -17,10 +19,13 @@ function renderUpdate(state) {
error: 'Could not update Capsule. You can keep working and try again.'
}
label.textContent = `Capsule ${state.currentVersion}${state.testing ? ' · Unsigned test channel' : ''} · ${messages[state.status] || messages.error}`
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 = state.status === 'disabled'
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)
install.hidden = state.status !== 'ready'
progress.hidden = state.status !== 'downloading'
@ -44,6 +49,15 @@ if (window.capsule?.updateStatus) {
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.disabled = true
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

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