diff --git a/CHANGELOG.md b/CHANGELOG.md index 518b7e6..35a271c 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 +- 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. diff --git a/README.md b/README.md index 54e67db..d69212f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ npm run dev ## Package and update +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. diff --git a/build/config.mjs b/build/config.mjs index 5164f93..1d26680 100644 --- a/build/config.mjs +++ b/build/config.mjs @@ -18,8 +18,13 @@ export function validateFeedUrl(value) { } /** Return a Windows build config; public releases fail closed without signing. */ -export function createBuildConfig({ release = false, env = process.env } = {}) { - const url = release ? validateFeedUrl(env.CAPSULE_UPDATE_URL) : null +export function createBuildConfig({ release = false, testUpdates = false, bootstrap = false, env = process.env } = {}) { + 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() if (release && !publisher) { throw new Error('CAPSULE_PUBLISHER_NAME must match the signing certificate subject CN.') @@ -31,7 +36,7 @@ export function createBuildConfig({ release = false, env = process.env } = {}) { appId: release ? APP_ID : `${APP_ID}.local`, productName: release ? 'Capsule' : 'Capsule Local', executableName: release ? 'Capsule' : 'Capsule Local', - 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'], asar: true, npmRebuild: false, @@ -39,12 +44,13 @@ export function createBuildConfig({ release = false, env = process.env } = {}) { extraMetadata: { // Keep the release userData path compatible with the original dev app. 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: release || testUpdates ? 'Capsule-${version}-${arch}-Setup.${ext}' : 'Capsule-Local-${version}-${arch}-Setup.${ext}', win: { target: [{ target: 'nsis', arch: ['x64'] }], - verifyUpdateCodeSignature: true, + verifyUpdateCodeSignature: !testUpdates, signExecutable: release, ...(release ? { signtoolOptions: { publisherName: publisher, @@ -60,6 +66,6 @@ export function createBuildConfig({ release = false, env = process.env } = {}) { runAfterFinish: false, shortcutName: release ? 'Capsule' : 'Capsule Local' }, - publish: release ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null + publish: release || testUpdates ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null } } diff --git a/docs/releases.md b/docs/releases.md index 4674415..9855ce0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -10,6 +10,28 @@ Local builds are unsigned, named **Capsule Local**, and have application ID `com 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. +## Free automatic-update testing + +The original Capsule Local 0.1.0 installer cannot check for updates. Install the update-enabled bootstrap once to start a real 0.1.0 to 0.1.1 test: + +```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 Local and preserves its existing session location. Close the existing app before installing it. `dist/test/Capsule-0.1.1-x64-Setup.exe` is the 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 appears after updating to 0.1.1. + +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.1, the dashboard message, and the preserved connection. Signed production upgrade acceptance remains a separate test. + ## 2. Configure the release feed Set `CAPSULE_UPDATE_URL` in the build environment to a permanent public HTTPS directory ending in `/`. The TTP Capsule plugin is installed on the LAN demo: use `https://ttp.joeykimsey.com/capsule/feed/` for updates and `https://ttp.joeykimsey.com/capsule/upload/` for uploads. These are environment settings, not hardcoded defaults. See the TTP checkout's `docs/capsule-hosting.md` for provisioning and token handoff. The URL must not contain login credentials, a query, or a fragment. The generated `app-update.yml` is baked into the signed application; site selection, login, and IPC cannot change it. diff --git a/package.json b/package.json index b1f7810..75d323e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "smoke": "electron scripts/smoke.cjs", "pack:win": "npm run build && node scripts/package.mjs --dir", "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", "release:win": "npm test && npm run build && node scripts/package.mjs --release", "publish:release": "node scripts/publish.mjs" diff --git a/scripts/package.mjs b/scripts/package.mjs index 29be5db..47e4ede 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -4,10 +4,13 @@ import { createBuildConfig } from '../build/config.mjs' import { writeReleaseManifest } from './releaseArtifacts.mjs' const args = process.argv.slice(2) -if (args.some((arg) => !['--release', '--dir'].includes(arg))) { - throw new Error('Usage: node scripts/package.mjs [--release] [--dir]') +if (args.some((arg) => !['--release', '--dir', '--test-updates', '--bootstrap'].includes(arg))) { + throw new Error('Usage: node scripts/package.mjs [--release | --test-updates [--bootstrap]] [--dir]') } 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.') // Never let a failed rebuild leave an older directory looking publishable. if (release) { @@ -15,12 +18,13 @@ if (release) { } else { 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({ config, targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64), publish: 'never' }) -if (release) { +if (release || testUpdates) { await writeReleaseManifest(config.directories.output, config.publish[0].url) } diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 19ae64c..44d6b09 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -2,8 +2,13 @@ import { verifyRelease } from './releaseArtifacts.mjs' import { uploadRelease } from './uploadRelease.mjs' const args = process.argv.slice(2) -if (args.some((arg) => arg !== '--upload')) throw new Error('Usage: npm run publish:release -- [--upload]') -const release = await verifyRelease('dist/release') +if (args.some((arg) => !['--upload', '--test-updates'].includes(arg))) throw new Error('Usage: npm run publish:release -- [--test-updates] [--upload]') +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}`) if (!args.includes('--upload')) { console.log('Verified. Upload order:') diff --git a/src/main/updateController.mjs b/src/main/updateController.mjs index 3e04cb5..319a919 100644 --- a/src/main/updateController.mjs +++ b/src/main/updateController.mjs @@ -1,7 +1,7 @@ /** 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 }) { - 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 busy = false let disposed = false diff --git a/src/main/updates.js b/src/main/updates.js index d3f6d6b..d833b6f 100644 --- a/src/main/updates.js +++ b/src/main/updates.js @@ -8,15 +8,18 @@ import { registerUpdateIpc, isTrustedUpdateSender } from './updateIpc.mjs' /** Start updates independently of site sign-in, using only packaged release config. */ export function registerUpdates(rendererUrl) { let enabled = false + let testing = false if (app.isPackaged && process.platform === 'win32') { const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8')) enabled = metadata.capsuleUpdates?.enabled === true + testing = metadata.capsuleUpdates?.testing === true && metadata.name === 'capsule-local' } const { autoUpdater } = electronUpdater const controller = createUpdateController({ updater: autoUpdater, version: app.getVersion(), enabled, + testing, onState(state) { const windows = BrowserWindow.getAllWindows() for (const window of windows) { diff --git a/src/renderer/index.html b/src/renderer/index.html index 9faf127..29b3d2b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -352,7 +352,7 @@

Dashboard

-

you have updated to 0.1.1

+

Connected to diff --git a/src/renderer/src/updates.js b/src/renderer/src/updates.js index 0bc77b5..91895f3 100644 --- a/src/renderer/src/updates.js +++ b/src/renderer/src/updates.js @@ -16,7 +16,8 @@ function renderUpdate(state) { installing: 'Restarting to install the update…', 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}` + document.getElementById('home-update-notice').hidden = state.currentVersion !== '0.1.1' check.hidden = state.status === 'disabled' check.disabled = ['checking', 'downloading', 'ready', 'installing'].includes(state.status) install.hidden = state.status !== 'ready' diff --git a/tests/releases.test.mjs b/tests/releases.test.mjs index 9df38d1..4230a54 100644 --- a/tests/releases.test.mjs +++ b/tests/releases.test.mjs @@ -37,6 +37,28 @@ test('local installer has separate identity, no feed, and cannot wipe app data', 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) => { const directory = await mkdtemp(join(tmpdir(), 'capsule-release-test-')) t.after(() => rm(directory, { recursive: true, force: true }))