add update functionality

This commit is contained in:
Joey Kimsey
2026-09-12 21:45:37 -04:00
parent 1ee2774241
commit 188702e516
7 changed files with 63 additions and 24 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- 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.
- 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.
- Release artifact checksums, metadata-last HTTPS publishing, a Windows automation entry point, updater/IPC tests, and packaging/signing/upgrade instructions in `docs/releases.md`.

View File

@ -60,6 +60,6 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
runAfterFinish: false,
shortcutName: release ? 'Capsule' : 'Capsule Local'
},
publish: release ? [{ provider: 'generic', url, channel: 'latest' }] : null
publish: release ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null
}
}

View File

@ -12,7 +12,7 @@ Public builds use the permanent ID `com.thetempusproject.capsule`, executable `C
## 2. Configure the release feed
Set `CAPSULE_UPDATE_URL` in the build environment to a permanent public HTTPS directory ending in `/`. No feed is hardcoded or configured yet. 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.
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.
The host serves these files at that URL:
@ -20,7 +20,7 @@ The host serves these files at that URL:
- `Capsule-<version>-x64-Setup.exe.blockmap`
- `latest.yml`, generated by electron-builder
Use immutable versioned filenames. Retain old release artifacts so downloads already in progress keep working. Serve the installer and blockmap as binary files, allow range requests for differential downloads, and avoid authentication/HTML challenge pages or response transformations. Metadata should revalidate (`Cache-Control: no-cache`); versioned artifacts can have long immutable cache lifetimes. Configure these response headers on the host; an upload request's headers alone do not configure every server.
Use immutable versioned filenames. Retain old release artifacts so downloads already in progress keep working. Serve the installer and blockmap as binary files, allow range requests for differential downloads, and avoid authentication/HTML challenge pages or response transformations. The plugin serves metadata and unpublished artifacts with `Cache-Control: no-store`; published versioned artifacts receive long immutable cache lifetimes. The generic provider disables multiple-range requests to match the host's single-range support.
Publishing `latest.yml` announces a release. Upload and verify both artifacts first. A running Capsule checks 15 seconds after launch and four hours after each scheduled check finishes. A manual check uses the same updater. No push service or background Windows service is installed. Immediate remote notifications can later request a check, but must never supply executable URLs or commands.
@ -44,18 +44,18 @@ The certificate provider determines how its private key is accessed; not all cer
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.
The output includes `release-ready.json`, a local record of the exact artifact hashes and feed. It is not served to clients and is not a substitute for Authenticode. Only files referenced by `latest.yml` plus the matching blockmap are publishable. Check both the installed executable and installer using `Get-AuthenticodeSignature` before the first public release.
The output includes `release-ready.json`, a local record of the exact artifact hashes and feed. Before recording hashes, the build rewrites `latest.yml` using JSON syntax, which is valid YAML and is the plugin's accepted metadata format. It is not served to clients and is not a substitute for Authenticode. Only files referenced by `latest.yml` plus the matching blockmap are publishable. Check both the installed executable and installer using `Get-AuthenticodeSignature` before the first public release.
## 5. Automate build and publication
`scripts/release.ps1` is the CI-independent Windows entry point. It runs `npm ci`, tests, the signed build, and release verification. Without `-Publish` it performs no upload. Keep build jobs serialized for each feed; concurrent publishers can otherwise move `latest.yml` backward. No CI service is configured because this checkout has no remote and no runner was selected.
`npm run publish:release` verifies the build and prints its upload order without uploading. The included upload adapter supports an authenticated HTTPS PUT/WebDAV endpoint. It needs:
`npm run publish:release` verifies the build and prints its upload order without uploading. The included upload adapter targets the TTP Capsule plugin's authenticated HTTPS PUT endpoint. It needs:
- `CAPSULE_UPLOAD_URL`: HTTPS upload directory, ending in `/`, mapped by the host to the public feed.
- `CAPSULE_UPLOAD_TOKEN`: a bearer token with upload access, available only on the release machine.
The host must support authenticated PUT, conditional creation with `If-None-Match: *`, and atomic replacement of `latest.yml`. This adapter is **not** an S3 signing client, SFTP client, or TTP API route. If hosting uses one of those transports, add the corresponding publisher adapter before enabling publication; client update code does not need to change.
The host must support authenticated PUT, conditional creation with `If-None-Match: *`, and the plugin's contiguous 512 KiB chunk protocol with `Content-Range` and a shared upload ID. Plain WebDAV and S3 do not assemble this protocol. The plugin verifies artifact hashes and atomically promotes metadata, rejecting downgrades and conflicting versions. Interrupted uploads restart with a new upload ID; abandoned parts are removed after 24 hours on a subsequent authenticated upload.
Run `npm run publish:release -- --upload`, or `powershell -File scripts/release.ps1 -Publish` for the complete pipeline. It verifies local checksums, uploads the installer and blockmap, verifies their publicly served bytes without credentials, and only then publishes `latest.yml`. Redirects are refused. Existing versioned objects are reused only if their public content matches. A failure stops the pipeline. If verification fails after `latest.yml` was uploaded, inspect and restore the feed pointer before retrying; the script does not claim a rollback.
@ -73,6 +73,6 @@ On a disposable Windows account or VM with the real signing identity and a stagi
4. Test offline checks and an interrupted download, restore connectivity, and retry. Try an installer signed by a different publisher and a tampered file on the isolated feed; neither may become installable.
5. Test a second launch, a normal uninstall (data retained), and reinstall. Confirm no unexpected elevation prompt. Verify the local build remains separate from the public product.
These installed/signed checks remain pending until a certificate and staging feed are provided. No live host, signing account, or public release is provisioned by the local build scripts.
These installed/signed checks remain pending until a signing identity is provided. The LAN demo host is online, with no public release announced. The local build scripts do not provision signing accounts or servers.
References: [electron-builder auto updates](https://www.electron.build/docs/features/auto-update/), [Windows configuration](https://www.electron.build/docs/win/). Consult the installed package APIs when newer online documentation describes features from a later major version.

View File

@ -10,6 +10,6 @@ if (!args.includes('--upload')) {
for (const file of release.files) console.log(` ${file.name} (${file.data.length} bytes)`)
console.log('No files uploaded. Add --upload with CAPSULE_UPLOAD_URL and CAPSULE_UPLOAD_TOKEN to publish.')
} else {
// This adapter targets an authenticated HTTPS PUT/WebDAV endpoint, not any TTP site API.
// Publish through the TTP Capsule plugin's authenticated chunk upload endpoint.
await uploadRelease(release, { uploadUrl: process.env.CAPSULE_UPLOAD_URL, token: process.env.CAPSULE_UPLOAD_TOKEN })
}

View File

@ -38,6 +38,10 @@ export async function readReleaseArtifacts(directory) {
/** Mark a successful signed build with hashes of its exact publishable files. */
export async function writeReleaseManifest(directory, feedUrl) {
// JSON is valid YAML. This gives the standalone PHP host a strict, dependency-free parser.
const metadataPath = join(directory, 'latest.yml')
const metadata = parse(await readFile(metadataPath, 'utf8'))
await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`)
const release = await readReleaseArtifacts(directory)
const manifest = {
version: release.version,

View File

@ -1,5 +1,8 @@
import { digest } from './releaseArtifacts.mjs'
import { validateFeedUrl } from '../build/config.mjs'
import { randomBytes } from 'node:crypto'
export const UPLOAD_CHUNK_SIZE = 512 * 1024
/** Publish immutable assets first and the feed pointer last, verifying public bytes. */
export async function uploadRelease(release, { uploadUrl, token, fetchImpl = fetch, log = console.log }) {
@ -7,21 +10,33 @@ export async function uploadRelease(release, { uploadUrl, token, fetchImpl = fet
if (!token || /[\r\n]/.test(token)) throw new Error('Set CAPSULE_UPLOAD_TOKEN in the release environment.')
for (const file of release.files) {
const metadata = file.name === 'latest.yml'
const response = await fetchImpl(new URL(file.name, uploadUrl), {
method: 'PUT',
redirect: 'error',
signal: AbortSignal.timeout(10 * 60 * 1000),
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': metadata ? 'application/yaml' : 'application/octet-stream',
'Cache-Control': metadata ? 'no-cache' : 'public, max-age=31536000, immutable',
...(!metadata ? { 'If-None-Match': '*' } : {})
},
body: file.data
})
// Existing immutable files are safe to reuse only after verifying their public bytes.
if (!response.ok && !(response.status === 412 && !metadata)) {
throw new Error(`Upload failed for ${file.name}: HTTP ${response.status}. Publication stopped.`)
const uploadId = randomBytes(16).toString('hex')
const chunked = !metadata && file.data.length > UPLOAD_CHUNK_SIZE
const step = chunked ? UPLOAD_CHUNK_SIZE : file.data.length
if (!step) throw new Error(`Cannot upload empty artifact ${file.name}`)
for (let offset = 0; offset < file.data.length; offset += step) {
const body = file.data.subarray(offset, offset + step)
const response = await fetchImpl(new URL(file.name, uploadUrl), {
method: 'PUT',
redirect: 'error',
signal: AbortSignal.timeout(10 * 60 * 1000),
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': metadata ? 'application/yaml' : 'application/octet-stream',
'Cache-Control': metadata ? 'no-cache' : 'public, max-age=31536000, immutable',
...(!metadata ? { 'If-None-Match': '*' } : {}),
...(chunked ? {
'Content-Range': `bytes ${offset}-${offset + body.length - 1}/${file.data.length}`,
'X-Capsule-Upload-Id': uploadId
} : {})
},
body
})
// Existing immutable files are safe to reuse only after verifying their public bytes.
if (!response.ok && !(response.status === 412 && !metadata)) {
throw new Error(`Upload failed for ${file.name}: HTTP ${response.status}. Publication stopped.`)
}
if (response.status === 412) break
}
const publicResponse = await fetchImpl(new URL(file.name, release.feedUrl), {
redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000), cache: 'no-store'

View File

@ -6,7 +6,7 @@ import { join } from 'node:path'
import { stringify } from 'yaml'
import { createBuildConfig, validateFeedUrl, APP_ID } from '../build/config.mjs'
import { digest, writeReleaseManifest, verifyRelease } from '../scripts/releaseArtifacts.mjs'
import { uploadRelease } from '../scripts/uploadRelease.mjs'
import { uploadRelease, UPLOAD_CHUNK_SIZE } from '../scripts/uploadRelease.mjs'
const feed = 'https://downloads.thetempusproject.com/capsule/windows/x64/'
const env = { CAPSULE_UPDATE_URL: feed, CAPSULE_PUBLISHER_NAME: 'Test Publisher', CSC_NAME: 'Test Publisher' }
@ -100,3 +100,22 @@ test('upload stops before metadata on failed upload or mismatched public artifac
assert.ok(calls.every((path) => !path.endsWith('latest.yml')))
}
})
test('large artifacts use contiguous bounded chunks and verify the assembled public file', async () => {
const data = Buffer.alloc(UPLOAD_CHUNK_SIZE * 2 + 17, 42)
const requests = []
await uploadRelease({ feedUrl: feed, files: [{ name: 'Capsule-0.1.1-x64-Setup.exe', data }] }, {
uploadUrl: 'https://upload.thetempusproject.com/capsule/upload/', token: 'test-token', log: () => {},
fetchImpl: async (_url, options) => {
if (options.method !== 'PUT') return { ok: true, arrayBuffer: async () => data }
requests.push(options)
return { ok: true, status: requests.length < 3 ? 202 : 201 }
}
})
assert.equal(requests.length, 3)
assert.deepEqual(requests.map((request) => request.headers['Content-Range']), [
`bytes 0-524287/${data.length}`, `bytes 524288-1048575/${data.length}`, `bytes 1048576-1048592/${data.length}`
])
assert.equal(new Set(requests.map((request) => request.headers['X-Capsule-Upload-Id'])).size, 1)
assert.deepEqual(Buffer.concat(requests.map((request) => request.body)), data)
})