Compare commits

...

10 Commits

Author SHA1 Message Date
188702e516 add update functionality 2026-09-12 21:45:37 -04:00
1ee2774241 add builder and updater 2026-09-12 21:21:28 -04:00
6e42b1012d copy update 2026-09-12 19:04:45 -04:00
7634219706 bug reports and contacts 2026-09-12 17:11:35 -04:00
0f5a33d6db profile fix and csrf protections 2026-09-12 16:46:38 -04:00
c0c257d5a4 avatar fix 2026-09-12 15:56:36 -04:00
8eba550b1d add messages 2026-09-12 15:33:52 -04:00
ded1e1e069 add footer 2026-09-12 15:16:50 -04:00
58ca586d94 mfa and api wiring 2026-09-12 15:11:21 -04:00
17d83f7209 add main appearance 2026-09-12 00:57:48 -04:00
29 changed files with 8479 additions and 453 deletions

6
.gitignore vendored
View File

@ -4,3 +4,9 @@ dist/
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.env
.env.*
*.pfx
*.p12
*.pem
dev-app-update.yml

View File

@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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`.
- Electron + electron-vite desktop shell with a login view and a connected workspace. - Electron + electron-vite desktop shell with a login view and a connected workspace.
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. - Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token. - Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) and post through `POST /api/contact` and `POST /api/bugreport`. Those footer items appear only when profile `features` (or `GET /api/contact` / `GET /api/bugreport`) say the plugin is available, accepting submissions, and allowed for this user. A hash to a disabled page shows the same “not accepting … right now” notice as the site. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, and Terms of Service. Contact and Report a Bug join that band only when the site is accepting them. There is no subscribe box.
### Changed
- 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.
### Fixed
- Settings no longer posts a display-name field. User CP has no such option; `users.name` is leftover, and `Check::name()` rejected typical values (`Invalid name.` / `malformed input`).
- Avatar file previews are allowed (`blob:` on `img-src`). Choosing a photo no longer shows the missing-image icon. A `{ "error": "not found" }` from the site is reported as a missing Capsule API action (the live TTP install still needs `POST /api/profile/update`).

View File

@ -6,7 +6,7 @@ This replaces the old `TempusToolkit` Electron stub. The keepers were the login
## Run it ## Run it
From this folder (Windows source checkout is fine <EFBFBD> Capsule is Node, not PHP): From this folder (Windows source checkout is fine ? Capsule is Node, not PHP):
```bash ```bash
npm install npm install
@ -15,17 +15,32 @@ npm run dev
`npm start` previews a production build. There is no local TTP site on the Windows checkout. Login needs a reachable install (typically the Ubuntu host) with `api/apiAccessPersonal` on for user-token calls. `npm start` previews a production build. There is no local TTP site on the Windows checkout. Login needs a reachable install (typically the Ubuntu host) with `api/apiAccessPersonal` on for user-token calls.
## 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.
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.
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.
## How auth works ## How auth works
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP<EFBFBD>s same-origin CORS policy does not apply. All HTTP runs in the **main process** (`net.fetch`, so the PHP session cookie sticks). The renderer never sees the token and never talks to the site directly, so TTP's same-origin CORS policy does not apply. POSTs send `X-CSRF-Token` (and POST `token`) after a GET harvests `csrf`.
| Action | Endpoint | Notes | | Action | Endpoint | Notes |
|--------|----------|-------| |--------|----------|-------|
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. No CSRF, no Turnstile. | | Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. CSRF from `GET /api/login`. No Turnstile. MFA accounts return `{ mfa }` instead of a token. |
| Confirm identity | `GET /api/users/find/{username}` | Bearer token. Returns a user id only. | | MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. |
| Existing token | Admin ? Tokens | Personal or app token. Username is optional and only used for that find call. | | MFA reset | `POST /api/login/mfa/{loginCode}/reset` | Clears the chosen method so the picker shows again. |
| Confirm identity | `GET /api/profile` | Bearer user token. Also used to hydrate username after login. `GET /api/users/find/{username}` remains available. |
| Workspace | `GET /api/notifications`, `GET /api/messages`, `GET /api/messages/recent` | First inbox page plus the header dropdown after connect. Plugin-off responses show as unavailable. |
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Avatar and prefs. |
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. |
| Contact / bugs | `GET` / `POST /api/contact`, `GET` / `POST /api/bugreport` | Footer pages when those plugins are enabled, accepting, and allowed. Hashing in while they are off shows a not-accepting notice. |
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. MFA `loginCode` is not stored.
App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`. App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
@ -35,7 +50,9 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
|------|-----| |------|-----|
| `src/main/` | Window, session file, TTP HTTP, IPC | | `src/main/` | Window, session file, TTP HTTP, IPC |
| `src/preload/` | `window.capsule` bridge | | `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login view and connected workspace | | `src/renderer/` | Login, MFA, TTP-styled chrome, and live API views |
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone ? those open `{site}/usercp/?`. Lists load from the site API after sign-in. The footer matches TTP copyright and social icons. Above that: Contact Us on the left (Contact and Report a Bug only when the site is accepting them), dark-mode in the middle, More Info (Privacy Policy, Terms of Service) on the right. No subscribe box. Privacy and terms open the connected site; contact and bug reports stay in-app.
## Remote ## Remote

65
build/config.mjs Normal file
View File

@ -0,0 +1,65 @@
/** Build identities and release requirements shared by packaging and tests. */
export const APP_ID = 'com.thetempusproject.capsule'
/** Validate a public feed without embedding credentials in installed copies. */
export function validateFeedUrl(value) {
let url
try {
url = new URL(value)
} catch {
throw new Error('CAPSULE_UPDATE_URL must be an absolute HTTPS directory URL.')
}
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
!url.pathname.endsWith('/') || /^(localhost|127\.|\[?::1\]?)/i.test(url.hostname) ||
/(^|\.)example\.(com|org|net)$|\.invalid$/i.test(url.hostname)) {
throw new Error('Use a permanent public HTTPS feed ending in /, without credentials, query, or fragment.')
}
return url.href
}
/** 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
const publisher = env.CAPSULE_PUBLISHER_NAME?.trim()
if (release && !publisher) {
throw new Error('CAPSULE_PUBLISHER_NAME must match the signing certificate subject CN.')
}
if (release && !env.CSC_LINK && !env.CSC_NAME) {
throw new Error('Set CSC_LINK (certificate) or CSC_NAME (Windows certificate store identity).')
}
return {
appId: release ? APP_ID : `${APP_ID}.local`,
productName: release ? 'Capsule' : 'Capsule Local',
executableName: release ? 'Capsule' : 'Capsule Local',
directories: { output: release ? 'dist/release' : 'dist/local' },
files: ['out/**/*', 'package.json'],
asar: true,
npmRebuild: false,
forceCodeSigning: release,
extraMetadata: {
// Keep the release userData path compatible with the original dev app.
name: release ? 'capsule' : 'capsule-local',
capsuleUpdates: { enabled: release }
},
artifactName: release ? 'Capsule-${version}-${arch}-Setup.${ext}' : 'Capsule-Local-${version}-${arch}-Setup.${ext}',
win: {
target: [{ target: 'nsis', arch: ['x64'] }],
verifyUpdateCodeSignature: true,
signExecutable: release,
...(release ? { signtoolOptions: {
publisherName: publisher,
signingHashAlgorithms: ['sha256'],
...(env.CSC_NAME ? { certificateSubjectName: env.CSC_NAME } : {})
} } : {})
},
nsis: {
oneClick: true,
perMachine: false,
allowElevation: false,
deleteAppDataOnUninstall: false,
runAfterFinish: false,
shortcutName: release ? 'Capsule' : 'Capsule Local'
},
publish: release ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null
}
}

78
docs/releases.md Normal file
View File

@ -0,0 +1,78 @@
# Packaging and releases
Capsule uses electron-builder 26.15.3 and electron-updater 6.8.9. The first distribution target is Windows x64 with a per-user NSIS installer. Build on Windows with Node 22.12+ and `npm ci`; the lockfile fixes the resolved dependency versions. No PHP server is needed to build the client.
## 1. Package the application
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.
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.
## 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.
The host serves these files at that URL:
- `Capsule-<version>-x64-Setup.exe`
- `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. 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.
## 3. Update behavior
`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.
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.
## 4. Sign a public release
Set these variables in a secure release environment:
- `CAPSULE_UPDATE_URL`: the public read-only feed directory.
- `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.
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. 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 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 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.
Never publish `dist/local/`, `win-unpacked/`, build debug files, certificate files, or `release-ready.json`. Increment `package.json` and the lockfile version before each release (`npm version patch --no-git-tag-version` is one option). Release metadata accepts stable `major.minor.patch` versions only. A separate beta feed/channel can be added later.
## Acceptance checks before public distribution
The automated tests exercise updater state transitions, duplicate checks, retryable failures, IPC boundaries, signing configuration, and artifact integrity. After `npm run build`, `npm run smoke` opens the compiled app hidden with an isolated `dist/smoke-profile` data directory, verifies the real preload/status bridge and a synthetic ready prompt, and captures `dist/local/smoke.png` and `smoke-ready.png`. It does not sign in or install anything. These checks do not substitute for an installed upgrade.
On a disposable Windows account or VM with the real signing identity and a staging HTTPS feed:
1. Build and install signed `0.1.0`; confirm the correct publisher, per-user installation, and launch. Save a site/session and preferences.
2. Build signed `0.1.1` against the same staging feed and publish it. Open `0.1.0`, confirm the download and restart prompt, then restart and verify the running version and preserved session/preferences.
3. Close without accepting the update: it must not install at quit. Reopen and check that the cached update can become ready again.
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 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.

3209
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,14 +9,24 @@
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"start": "electron-vite preview", "start": "electron-vite preview",
"build": "electron-vite build" "build": "electron-vite build",
"test": "node --test tests/*.test.mjs",
"smoke": "electron scripts/smoke.cjs",
"pack:win": "npm run build && node scripts/package.mjs --dir",
"dist:win": "npm run build && node scripts/package.mjs",
"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"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0" "@electron-toolkit/utils": "^4.0.0",
"electron-updater": "6.8.9"
}, },
"devDependencies": { "devDependencies": {
"electron": "^39.2.6", "electron": "^39.2.6",
"electron-builder": "26.15.3",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"vite": "^7.2.6" "vite": "^7.2.6",
"yaml": "2.9.1"
} }
} }

View File

@ -0,0 +1,4 @@
import { rm } from 'node:fs/promises'
// A failed test/build must not leave a previous release marked as ready to publish.
await rm('dist/release/release-ready.json', { force: true })

26
scripts/package.mjs Normal file
View File

@ -0,0 +1,26 @@
import { build, Platform, Arch } from 'electron-builder'
import { rm } from 'node:fs/promises'
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]')
}
const release = args.includes('--release')
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) {
await rm('dist/release/release-ready.json', { force: true })
} else {
process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
}
const config = createBuildConfig({ release })
await build({
config,
targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64),
publish: 'never'
})
if (release) {
await writeReleaseManifest(config.directories.output, config.publish[0].url)
}

15
scripts/publish.mjs Normal file
View File

@ -0,0 +1,15 @@
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')
console.log(`Capsule ${release.version}: ${release.feedUrl}`)
if (!args.includes('--upload')) {
console.log('Verified. Upload order:')
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 {
// 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 })
}

20
scripts/release.ps1 Normal file
View File

@ -0,0 +1,20 @@
param([switch]$Publish)
$ErrorActionPreference = 'Stop'
Push-Location (Split-Path $PSScriptRoot -Parent)
try {
# Invalidate any older result even if npm ci or tests fail.
Remove-Item -LiteralPath 'dist/release/release-ready.json' -Force -ErrorAction SilentlyContinue
npm ci
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' }
npm run release:win
if ($LASTEXITCODE -ne 0) { throw 'Signed release build failed.' }
if ($Publish) {
npm run publish:release -- --upload
} else {
npm run publish:release
}
if ($LASTEXITCODE -ne 0) { throw 'Release verification or publication failed.' }
} finally {
Pop-Location
}

View File

@ -0,0 +1,64 @@
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createHash } from 'node:crypto'
import { parse } from 'yaml'
import { validateFeedUrl } from '../build/config.mjs'
/** Hash an artifact for local tamper/corruption checks and upload verification. */
export function digest(data, algorithm = 'sha256', encoding = 'hex') {
return createHash(algorithm).update(data).digest(encoding)
}
/** Read only the x64 stable installer referenced by electron-builder metadata. */
export async function readReleaseArtifacts(directory) {
const metadata = await readFile(join(directory, 'latest.yml'))
const info = parse(metadata.toString('utf8'))
if (!/^\d+\.\d+\.\d+$/.test(info?.version) || info.files?.length !== 1) {
throw new Error('Expected one Windows x64 installer for a stable semantic version.')
}
const installer = `Capsule-${info.version}-x64-Setup.exe`
if (info.files[0].url !== installer || (info.path && info.path !== installer)) {
throw new Error('Release metadata must reference the expected local installer filename.')
}
const binary = await readFile(join(directory, installer))
if (info.files[0].sha512 !== digest(binary, 'sha512', 'base64') || info.files[0].size !== binary.length) {
throw new Error('Installer does not match latest.yml checksum or size.')
}
const blockmap = await readFile(join(directory, `${installer}.blockmap`))
return {
version: info.version,
files: [
{ name: installer, data: binary },
{ name: `${installer}.blockmap`, data: blockmap },
// Publish the pointer only after both immutable artifacts are available.
{ name: 'latest.yml', data: metadata }
]
}
}
/** 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,
feedUrl: validateFeedUrl(feedUrl),
files: release.files.map(({ name, data }) => ({ name, sha256: digest(data) }))
}
await writeFile(join(directory, 'release-ready.json'), JSON.stringify(manifest, null, 2))
}
/** Refuse local builds, missing files, stale metadata, and changed release assets. */
export async function verifyRelease(directory) {
const manifest = JSON.parse(await readFile(join(directory, 'release-ready.json'), 'utf8'))
validateFeedUrl(manifest.feedUrl)
const release = await readReleaseArtifacts(directory)
if (release.version !== manifest.version || manifest.files?.length !== release.files.length ||
release.files.some(({ name, data }, index) => manifest.files[index].name !== name || manifest.files[index].sha256 !== digest(data))) {
throw new Error('Release artifacts have changed since the signed build. Rebuild before publishing.')
}
return { ...release, feedUrl: manifest.feedUrl }
}

61
scripts/smoke.cjs Normal file
View File

@ -0,0 +1,61 @@
// Launch the compiled app with isolated data and capture its update panel without signing in.
const { app } = require('electron')
const { mkdirSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const assert = require('node:assert/strict')
const profile = join(__dirname, '../dist/smoke-profile')
mkdirSync(profile, { recursive: true })
mkdirSync(join(__dirname, '../dist/local'), { recursive: true })
app.setName('capsule-local')
app.setVersion(require('../package.json').version)
app.setPath('appData', profile)
const timeout = setTimeout(() => {
console.error('Smoke test timed out.')
app.exit(1)
}, 30000)
app.on('browser-window-created', (_event, window) => {
window.show = () => {} // Exercise the real app without interrupting the user's desktop.
window.webContents.once('did-finish-load', async () => {
try {
const state = await window.webContents.executeJavaScript(`(async () => {
const state = await window.capsule.updateStatus()
await window.capsule.checkForUpdates()
await new Promise(resolve => setTimeout(resolve, 250))
return {
state,
panelVisible: !document.getElementById('capsule-updates').hidden,
checkHidden: document.getElementById('update-check').hidden,
restartHidden: document.getElementById('update-install').hidden,
label: document.getElementById('update-status').textContent
}
})()`)
assert.equal(state.state.status, 'disabled')
assert.equal(state.state.currentVersion, require('../package.json').version)
assert.equal(state.panelVisible, true)
assert.equal(state.checkHidden, true)
assert.equal(state.restartHidden, true)
assert.match(state.label, /automatic updates are disabled/)
assert.equal(app.getPath('userData'), join(profile, 'capsule-local'))
const image = await window.webContents.capturePage()
writeFileSync(join(__dirname, '../dist/local/smoke.png'), image.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'))
}, 100))`)
assert.equal(readyVisible, true)
writeFileSync(join(__dirname, '../dist/local/smoke-ready.png'), (await window.webContents.capturePage()).toPNG())
console.log(JSON.stringify({ smoke: 'passed', ...state }))
clearTimeout(timeout)
app.exit(0)
} catch (error) {
console.error(error)
app.exit(1)
}
})
})
require('../out/main/index.js')

49
scripts/uploadRelease.mjs Normal file
View File

@ -0,0 +1,49 @@
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 }) {
uploadUrl = validateFeedUrl(uploadUrl)
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 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'
})
if (!publicResponse.ok || digest(Buffer.from(await publicResponse.arrayBuffer())) !== digest(file.data)) {
throw new Error(`Public verification failed for ${file.name}. Publication stopped.`)
}
log(`Published and verified ${file.name}`)
}
}

254
src/main/apiIpc.js Normal file
View File

@ -0,0 +1,254 @@
/**
* IPC handlers for signed-in TTP API calls. Token stays in main.
*/
import { ipcMain } from 'electron'
import {
apiErrorMessage,
createMessage,
deleteMessage,
deleteNotification,
getBugreportStatus,
getContactStatus,
getProfile,
isDeadTokenError,
listMessages,
listNotifications,
readMessage,
readNotification,
recentMessages,
replyMessage,
searchSite,
sendBugreport,
sendContact,
unreadMessage,
unwrapApi,
updateProfile,
viewMessage
} from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
/**
* Live site + token, or throw.
*
* @return {object} - stored session
*/
function requireSession() {
const session = readSession()
if (!session?.token || !session.siteUrl) {
throw new Error('Sign in first.')
}
return session
}
/**
* Clear the stored token when the API rejected it.
*
* @param {object} session - stored session
* @param {Error} err - thrown API error
* @return {void}
*/
function dropDeadToken(session, err) {
if (err?.code !== 'DEAD_TOKEN') {
return
}
writeLastSite(session.siteUrl)
}
/**
* Run a signed-in API call and unwrap `{ error }`.
*
* @param {(session: object) => Promise<object>} work - HTTP call
* @return {Promise<object>}
*/
async function runUserApi(work) {
const session = requireSession()
try {
return unwrapApi(await work(session))
} catch (err) {
dropDeadToken(session, err)
throw err
}
}
/**
* List payload, or an unavailable marker when the plugin is off.
*
* @param {object} data - parsed JSON
* @return {object}
*/
function optionalList(data) {
if (!data?.error) {
return data
}
if (isDeadTokenError(data.error)) {
unwrapApi(data)
}
return {
items: [],
unread: 0,
page: 1,
pages: 0,
total: 0,
unavailable: true,
error: apiErrorMessage(data.error, data.errors)
}
}
/**
* Register user-token API IPC. Call once after app ready.
*
* @return {void}
*/
export function registerApiIpc() {
ipcMain.handle('capsule:workspace', async () => {
const session = requireSession()
try {
const profile = await getProfile(session.siteUrl, session.token)
unwrapApi(profile)
session.username = profile.user?.username || session.username
session.userId = profile.user?.id ?? session.userId
session.apiReady = true
writeSession(session)
const [notifications, messages, recent] = await Promise.all([
listNotifications(session.siteUrl, session.token, 1),
listMessages(session.siteUrl, session.token, 1),
recentMessages(session.siteUrl, session.token, 5)
])
return {
session: publicSession(session),
profile,
notifications: optionalList(notifications),
messages: optionalList(messages),
recentMessages: optionalList(recent)
}
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:profile', async () => {
return runUserApi((session) => getProfile(session.siteUrl, session.token))
})
ipcMain.handle('capsule:updateProfile', async (_event, payload) => {
return runUserApi((session) =>
updateProfile(session.siteUrl, session.token, payload?.fields || {}, payload?.avatar)
)
})
ipcMain.handle('capsule:notifications', async (_event, payload) => {
const session = requireSession()
try {
return optionalList(
await listNotifications(session.siteUrl, session.token, payload?.page || 1)
)
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:notificationRead', async (_event, payload) => {
return runUserApi((session) => readNotification(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:notificationDelete', async (_event, payload) => {
return runUserApi((session) => deleteNotification(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:messages', async (_event, payload) => {
const session = requireSession()
try {
return optionalList(await listMessages(session.siteUrl, session.token, payload?.page || 1))
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:messagesRecent', async (_event, payload) => {
const session = requireSession()
try {
return optionalList(
await recentMessages(session.siteUrl, session.token, payload?.limit || 5)
)
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:messageView', async (_event, payload) => {
return runUserApi((session) =>
viewMessage(session.siteUrl, session.token, payload?.id, payload?.markRead !== false)
)
})
ipcMain.handle('capsule:messageCreate', async (_event, payload) => {
return runUserApi((session) =>
createMessage(session.siteUrl, session.token, payload?.toUser, payload?.message)
)
})
ipcMain.handle('capsule:messageReply', async (_event, payload) => {
return runUserApi((session) =>
replyMessage(session.siteUrl, session.token, payload?.id, payload?.message)
)
})
ipcMain.handle('capsule:messageRead', async (_event, payload) => {
return runUserApi((session) => readMessage(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:messageUnread', async (_event, payload) => {
return runUserApi((session) => unreadMessage(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:messageDelete', async (_event, payload) => {
return runUserApi((session) => deleteMessage(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:search', async (_event, payload) => {
return runUserApi((session) =>
searchSite(session.siteUrl, session.token, {
q: payload?.q || '',
resource: payload?.resource || 'all',
page: payload?.page || 1,
results: payload?.results || ''
})
)
})
ipcMain.handle('capsule:contactStatus', async () => {
return runUserApi((session) => getContactStatus(session.siteUrl, session.token))
})
ipcMain.handle('capsule:contact', async (_event, payload) => {
return runUserApi((session) =>
sendContact(session.siteUrl, session.token, {
name: payload?.name || '',
entry: payload?.entry || '',
contactEmail: payload?.contactEmail || payload?.email || ''
})
)
})
ipcMain.handle('capsule:bugreportStatus', async () => {
return runUserApi((session) => getBugreportStatus(session.siteUrl, session.token))
})
ipcMain.handle('capsule:bugreport', async (_event, payload) => {
return runUserApi((session) =>
sendBugreport(session.siteUrl, session.token, {
url: payload?.url || '',
ourl: payload?.ourl || '',
repeat: payload?.repeat,
entry: payload?.entry || ''
})
)
})
}

View File

@ -1,7 +1,26 @@
import { app, BrowserWindow, shell } from 'electron' import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path' import { join } from 'path'
import { pathToFileURL } from 'node:url'
import { electronApp, is, optimizer } from '@electron-toolkit/utils' import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { registerApiIpc } from './apiIpc.js'
import { registerSessionIpc } from './sessionIpc.js' import { registerSessionIpc } from './sessionIpc.js'
import { registerUpdates } from './updates.js'
const rendererUrl = is.dev && process.env.ELECTRON_RENDERER_URL
? process.env.ELECTRON_RENDERER_URL
: pathToFileURL(join(__dirname, '../renderer/index.html')).href
// electron-builder's productName must not move existing release session data.
app.setPath('userData', join(app.getPath('appData'), app.getName() === 'capsule-local' ? 'capsule-local' : 'capsule'))
const hasInstanceLock = app.requestSingleInstanceLock()
if (!hasInstanceLock) app.quit()
else app.on('second-instance', () => {
const window = BrowserWindow.getAllWindows()[0]
if (window) {
if (window.isMinimized()) window.restore()
window.focus()
}
})
/** /**
* Open the main Capsule window. * Open the main Capsule window.
@ -10,10 +29,10 @@ import { registerSessionIpc } from './sessionIpc.js'
*/ */
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 920, width: 1180,
height: 640, height: 760,
minWidth: 760, minWidth: 900,
minHeight: 520, minHeight: 600,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
title: 'Capsule', title: 'Capsule',
@ -42,12 +61,14 @@ function createWindow() {
} }
} }
app.whenReady().then(() => { if (hasInstanceLock) app.whenReady().then(() => {
electronApp.setAppUserModelId('com.thetempusproject.capsule') electronApp.setAppUserModelId(app.getName() === 'capsule-local' ? 'com.thetempusproject.capsule.local' : 'com.thetempusproject.capsule')
app.on('browser-window-created', (_event, window) => { app.on('browser-window-created', (_event, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
registerSessionIpc() registerSessionIpc()
registerApiIpc()
registerUpdates(rendererUrl)
createWindow() createWindow()
app.on('activate', () => { app.on('activate', () => {

View File

@ -1,11 +1,23 @@
/** /**
* IPC handlers for login, token connect, logout, and session reads. * IPC handlers for login, MFA, token connect, logout, and session reads.
*/ */
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { findUser, isDeadTokenError, loginWithPassword, normalizeSiteUrl } from './ttpClient.js' import {
getProfile,
isDeadTokenError,
loginWithPassword,
normalizeSiteUrl,
resetMfaMethod,
selectMfaMethod,
submitMfaCode,
clearCsrf
} from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js' import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
/** In-memory MFA challenge. Never written to session.json. */
let pendingMfa = null
/** /**
* Build a stored session after a successful auth. * Build a stored session after a successful auth.
* *
@ -19,30 +31,32 @@ import { publicSession, readSession, writeLastSite, writeSession } from './sessi
async function persistConnection(fields) { async function persistConnection(fields) {
const siteUrl = fields.siteUrl const siteUrl = fields.siteUrl
const token = fields.token const token = fields.token
const username = String(fields.username || '').trim() let username = String(fields.username || '').trim()
const authMethod = fields.authMethod const authMethod = fields.authMethod
let userId = null let userId = null
let apiReady = false let apiReady = false
if (username) {
try { try {
const found = await findUser(siteUrl, token, username) const profile = await getProfile(siteUrl, token)
if (isDeadTokenError(found.error)) { if (profile.error) {
if (isDeadTokenError(profile.error)) {
const dead = new Error( const dead = new Error(
found.error === 'token expired' ? 'That API token has expired.' : 'That API token was not accepted.' profile.error === 'token expired' ? 'That API token has expired.' : 'That API token was not accepted.'
) )
dead.code = 'DEAD_TOKEN' dead.code = 'DEAD_TOKEN'
throw dead throw dead
} }
userId = found.userId } else if (profile.user) {
apiReady = found.userId !== null username = profile.user.username || username
userId = profile.user.id ?? null
apiReady = true
}
} catch (err) { } catch (err) {
if (err?.code === 'DEAD_TOKEN') { if (err?.code === 'DEAD_TOKEN') {
throw err throw err
} }
apiReady = false apiReady = false
} }
}
const session = { const session = {
siteUrl, siteUrl,
@ -57,6 +71,106 @@ async function persistConnection(fields) {
return publicSession(session) return publicSession(session)
} }
/**
* MFA fields the renderer may see. loginCode stays in main.
*
* @param {object} mfa - API challenge payload
* @return {object} - public pending-MFA session
*/
/**
* Challenge fields the renderer may see. No loginCode.
*
* @param {object} mfa - API or stored challenge
* @return {object}
*/
function publicMfaFields(mfa) {
return {
method: mfa?.method || '',
methods: Array.isArray(mfa?.methods) ? mfa.methods : [],
prompt: mfa?.prompt || 'Choose how you want to authenticate.'
}
}
/**
* MFA fields the renderer may see. loginCode stays in main.
*
* @param {object} [mfa] - public challenge fields
* @return {object} - public pending-MFA session
*/
function publicPendingMfa(mfa) {
const challenge = mfa || pendingMfa?.mfa || {}
return {
connected: false,
pendingMfa: true,
siteUrl: pendingMfa?.siteUrl || '',
username: pendingMfa?.username || '',
lastSiteUrl: pendingMfa?.siteUrl || '',
mfa: publicMfaFields(challenge)
}
}
/**
* Remember a live MFA challenge in process memory.
*
* @param {string} siteUrl - canonical site base
* @param {string} username - TTP username
* @param {object} mfa - API challenge payload
* @return {object} - public pending-MFA session
*/
function rememberPendingMfa(siteUrl, username, mfa) {
pendingMfa = {
siteUrl,
username,
loginCode: mfa.loginCode,
mfa: publicMfaFields(mfa)
}
return publicPendingMfa(pendingMfa.mfa)
}
/**
* Drop the in-memory MFA challenge.
*
* @return {void}
*/
function clearPendingMfa() {
pendingMfa = null
}
/**
* Turn a token-or-mfa API result into a public session.
*
* @param {object} result - { token } or { mfa }
* @return {Promise<object>}
*/
async function finishAuthResult(result) {
if (result?.mfa) {
if (!pendingMfa) {
throw new Error('Sign in first.')
}
if (result.mfa.loginCode) {
pendingMfa.loginCode = result.mfa.loginCode
}
pendingMfa.mfa = publicMfaFields(result.mfa)
return publicPendingMfa(pendingMfa.mfa)
}
const siteUrl = pendingMfa.siteUrl
const username = pendingMfa.username
clearPendingMfa()
return persistConnection({ siteUrl, token: result.token, username, authMethod: 'password' })
}
/**
* Require a live in-memory MFA challenge.
*
* @return {object} - pending row
*/
function requirePendingMfa() {
if (!pendingMfa?.loginCode || !pendingMfa.siteUrl) {
throw new Error('Sign in first.')
}
return pendingMfa
}
/** /**
* Register session IPC. Call once after app ready. * Register session IPC. Call once after app ready.
* *
@ -64,6 +178,9 @@ async function persistConnection(fields) {
*/ */
export function registerSessionIpc() { export function registerSessionIpc() {
ipcMain.handle('capsule:session', () => { ipcMain.handle('capsule:session', () => {
if (pendingMfa) {
return publicPendingMfa(pendingMfa.mfa)
}
return publicSession(readSession()) return publicSession(readSession())
}) })
@ -76,8 +193,66 @@ export function registerSessionIpc() {
throw new Error('Username and password are required.') throw new Error('Username and password are required.')
} }
const token = await loginWithPassword(siteUrl, username, password) clearPendingMfa()
return persistConnection({ siteUrl, token, username, authMethod: 'password' }) clearCsrf(siteUrl)
const result = await loginWithPassword(siteUrl, username, password)
if (result.mfa) {
return rememberPendingMfa(siteUrl, username, result.mfa)
}
return persistConnection({ siteUrl, token: result.token, username, authMethod: 'password' })
})
ipcMain.handle('capsule:mfaChallenge', async (_event, payload) => {
const pending = requirePendingMfa()
const authCode = String(payload?.authCode || '').replace(/\D/g, '')
if (authCode.length < 6) {
throw new Error('Please enter your authentication code.')
}
try {
return await finishAuthResult(await submitMfaCode(pending.siteUrl, pending.loginCode, authCode))
} catch (err) {
if (String(err?.message || '').includes('expired')) {
clearPendingMfa()
}
throw err
}
})
ipcMain.handle('capsule:mfaSelect', async (_event, payload) => {
const pending = requirePendingMfa()
const method = String(payload?.method || '')
if (!method) {
throw new Error('Choose how you want to authenticate.')
}
try {
return await finishAuthResult(await selectMfaMethod(pending.siteUrl, pending.loginCode, method))
} catch (err) {
if (String(err?.message || '').includes('expired')) {
clearPendingMfa()
}
throw err
}
})
ipcMain.handle('capsule:mfaReset', async () => {
const pending = requirePendingMfa()
try {
return await finishAuthResult(await resetMfaMethod(pending.siteUrl, pending.loginCode))
} catch (err) {
if (String(err?.message || '').includes('expired')) {
clearPendingMfa()
}
throw err
}
})
ipcMain.handle('capsule:mfaCancel', () => {
const lastSiteUrl = pendingMfa?.siteUrl || ''
clearPendingMfa()
if (lastSiteUrl) {
writeLastSite(lastSiteUrl)
}
return publicSession(readSession())
}) })
ipcMain.handle('capsule:connectToken', async (_event, payload) => { ipcMain.handle('capsule:connectToken', async (_event, payload) => {
@ -89,6 +264,8 @@ export function registerSessionIpc() {
throw new Error('Paste an API token.') throw new Error('Paste an API token.')
} }
clearPendingMfa()
clearCsrf(siteUrl)
return persistConnection({ siteUrl, token, username, authMethod: 'token' }) return persistConnection({ siteUrl, token, username, authMethod: 'token' })
}) })
@ -98,19 +275,21 @@ export function registerSessionIpc() {
return publicSession(session) return publicSession(session)
} }
if (!session.username) {
return publicSession(session)
}
try { try {
const found = await findUser(session.siteUrl, session.token, session.username) const profile = await getProfile(session.siteUrl, session.token)
if (isDeadTokenError(found.error)) { if (profile.error) {
if (isDeadTokenError(profile.error)) {
writeLastSite(session.siteUrl) writeLastSite(session.siteUrl)
return publicSession(readSession()) return publicSession(readSession())
} }
session.apiReady = false
writeSession(session)
return publicSession(session)
}
session.userId = found.userId session.username = profile.user?.username || session.username
session.apiReady = found.userId !== null session.userId = profile.user?.id ?? session.userId
session.apiReady = true
writeSession(session) writeSession(session)
return publicSession(session) return publicSession(session)
} catch { } catch {
@ -120,7 +299,12 @@ export function registerSessionIpc() {
ipcMain.handle('capsule:logout', () => { ipcMain.handle('capsule:logout', () => {
const session = readSession() const session = readSession()
writeLastSite(session?.siteUrl || session?.lastSiteUrl || '') const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || ''
clearPendingMfa()
writeLastSite(lastSiteUrl)
if (lastSiteUrl) {
clearCsrf(lastSiteUrl)
}
return publicSession(readSession()) return publicSession(readSession())
}) })
} }

View File

@ -1,7 +1,13 @@
/** /**
* HTTP calls to a TTP site. Runs in the main process so CORS does not apply. * HTTP calls to a TTP site. Runs in the main process so CORS does not apply.
* Uses Electron net.fetch so the PHP session cookie (CSRF) persists.
*/ */
import { net, session as electronSession } from 'electron'
/** Session CSRF tokens keyed by canonical site URL. */
const csrfBySite = new Map()
/** /**
* Normalize a TTP site URL to scheme + host + optional path, no trailing slash. * Normalize a TTP site URL to scheme + host + optional path, no trailing slash.
* *
@ -29,6 +35,100 @@ export function normalizeSiteUrl(raw) {
return `${parsed.origin}${path === '/' ? '' : path}` return `${parsed.origin}${path === '/' ? '' : path}`
} }
/**
* Forget stored CSRF for one site, or all sites.
*
* @param {string} [siteUrl] - canonical site base
* @return {void}
*/
export function clearCsrf(siteUrl) {
if (siteUrl) {
csrfBySite.delete(siteUrl)
return
}
csrfBySite.clear()
}
/**
* Store a CSRF value from a response header or JSON body.
*
* @param {string} siteUrl - canonical site base
* @param {Response} response - fetch response
* @param {object} [data] - parsed JSON
* @return {void}
*/
function rememberCsrf(siteUrl, response, data) {
const header = response?.headers?.get?.('x-csrf-token') || ''
const body = typeof data?.csrf === 'string' ? data.csrf : ''
const value = body || header
if (value && siteUrl) {
csrfBySite.set(siteUrl, value)
}
}
/**
* True when the API rejected the POST for a missing or stale CSRF token.
*
* @param {object} data - parsed JSON
* @return {boolean}
*/
function isCsrfFailure(data) {
if (data?.error !== 'malformed input') {
return false
}
const errors = data.errors
const text = typeof errors === 'string' ? errors : JSON.stringify(errors || '')
return text.includes('Invalid Token')
}
/**
* GET a CSRF token when this site has none yet.
*
* @param {string} siteUrl - canonical site base
* @param {string} [token] - Bearer token
* @return {Promise<void>}
*/
async function primeCsrf(siteUrl, token) {
if (!siteUrl || csrfBySite.get(siteUrl)) {
return
}
if (token) {
await ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token, skipCsrfPrime: true })
return
}
await ttpRequest({ siteUrl, path: '/api/login', method: 'GET', skipCsrfPrime: true })
}
/**
* Attach the stored CSRF token to headers and the POST body.
*
* @param {string} siteUrl - canonical site base
* @param {Record<string, string>} headers - request headers
* @param {Record<string, string>} [form] - urlencoded fields
* @param {FormData|Record<string, string|Blob>} [multipart] - multipart fields
* @return {{form?: Record<string, string>, multipart?: FormData|Record<string, string|Blob>}}
*/
function attachCsrf(siteUrl, headers, form, multipart) {
const csrf = csrfBySite.get(siteUrl)
if (!csrf) {
return { form, multipart }
}
headers['X-CSRF-Token'] = csrf
if (multipart instanceof FormData) {
if (!multipart.has('token')) {
multipart.append('token', csrf)
}
return { form, multipart }
}
if (multipart && typeof multipart === 'object') {
return { form, multipart: { ...multipart, token: csrf } }
}
if (form && typeof form === 'object') {
return { form: { ...form, token: csrf }, multipart }
}
return { form: { token: csrf, submit: '1' }, multipart }
}
/** /**
* Call a TTP API path and parse JSON. * Call a TTP API path and parse JSON.
* *
@ -38,6 +138,10 @@ export function normalizeSiteUrl(raw) {
* @param {string} [options.method='GET'] - HTTP method * @param {string} [options.method='GET'] - HTTP method
* @param {string} [options.token] - Bearer token * @param {string} [options.token] - Bearer token
* @param {Record<string, string>} [options.form] - urlencoded body * @param {Record<string, string>} [options.form] - urlencoded body
* @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar)
* @param {Record<string, string|number>} [options.query] - query string
* @param {boolean} [options.skipCsrfPrime] - skip the GET that harvests CSRF
* @param {boolean} [options.retriedCsrf] - already retried after Invalid Token
* @return {Promise<object>} - parsed JSON * @return {Promise<object>} - parsed JSON
*/ */
export async function ttpRequest(options) { export async function ttpRequest(options) {
@ -45,23 +149,64 @@ export async function ttpRequest(options) {
const path = options.path const path = options.path
const method = options.method || 'GET' const method = options.method || 'GET'
const token = options.token const token = options.token
const form = options.form
const url = `${siteUrl}${path}`
const headers = { Accept: 'application/json' } const headers = { Accept: 'application/json' }
let url = `${siteUrl}${path}`
let form = options.form
let multipart = options.multipart
let body let body
if (method === 'POST' && !options.skipCsrfPrime) {
await primeCsrf(siteUrl, token)
const attached = attachCsrf(siteUrl, headers, form, multipart)
form = attached.form
multipart = attached.multipart
}
if (options.query && typeof options.query === 'object') {
const qs = new URLSearchParams()
Object.entries(options.query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return
}
qs.set(key, String(value))
})
const encoded = qs.toString()
if (encoded) {
url += (path.includes('?') ? '&' : '?') + encoded
}
}
if (token) { if (token) {
headers.Authorization = `Bearer ${token}` headers.Authorization = `Bearer ${token}`
} }
if (form) { if (multipart instanceof FormData) {
body = multipart
} else if (multipart) {
const data = new FormData()
Object.entries(multipart).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return
}
data.append(key, value)
})
body = data
} else if (form) {
headers['Content-Type'] = 'application/x-www-form-urlencoded' headers['Content-Type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(form).toString() body = new URLSearchParams(form).toString()
} else if (method === 'POST') {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
body = 'submit=1'
} }
let response let response
try { try {
response = await fetch(url, { method, headers, body, redirect: 'follow' }) response = await net.fetch(url, {
method,
headers,
body,
session: electronSession.defaultSession
})
} catch { } catch {
throw new Error('Could not reach that site.') throw new Error('Could not reach that site.')
} }
@ -74,6 +219,13 @@ export async function ttpRequest(options) {
throw new Error(`The site did not return API JSON (${response.status}).`) throw new Error(`The site did not return API JSON (${response.status}).`)
} }
rememberCsrf(siteUrl, response, data)
if (method === 'POST' && isCsrfFailure(data) && !options.retriedCsrf) {
clearCsrf(siteUrl)
return ttpRequest({ ...options, retriedCsrf: true })
}
return data return data
} }
@ -81,12 +233,13 @@ export async function ttpRequest(options) {
* Map a TTP API error string to a short user-facing line. * Map a TTP API error string to a short user-facing line.
* *
* @param {string} code - API `error` value * @param {string} code - API `error` value
* @param {unknown} [errors] - optional Check user errors
* @return {string} - message for the login form * @return {string} - message for the login form
*/ */
export function apiErrorMessage(code) { export function apiErrorMessage(code, errors) {
switch (code) { switch (code) {
case 'malformed input': case 'malformed input':
return 'Username and password are required.' return firstUserError(errors) || 'Check the form and try again.'
case 'bad credentials': case 'bad credentials':
return 'Those credentials were not accepted.' return 'Those credentials were not accepted.'
case 'invalid token': case 'invalid token':
@ -96,18 +249,93 @@ export function apiErrorMessage(code) {
return 'That API token has expired.' return 'That API token has expired.'
case 'IRDK': case 'IRDK':
return 'The site could not refresh this token.' return 'The site could not refresh this token.'
case 'no valid MFA methods':
return 'This account has no usable MFA method. Contact support.'
case 'invalid MFA':
return 'That authentication code was not accepted.'
case 'MFA expired':
return 'This sign-in challenge expired. Sign in again.'
case 'Could not send MFA code':
return 'Could not send an authentication code. Try another method.'
case 'Choose an MFA method':
return 'Choose how you want to authenticate.'
case 'user token required':
return 'This action needs a personal (user) token, not an app token.'
case 'not found':
return 'This site is missing that Capsule API action. Pull the latest TTP on the server.'
case 'Contact submissions are disabled.':
return 'The contact form is not accepting new messages right now.'
case 'Bug report submissions are disabled.':
return 'New bug reports are not being accepted right now.'
case 'Contact is not available':
return 'The contact form is not available.'
case 'Bug reports is not available':
return 'Bug reports are not available.'
default: default:
return code || 'The site returned an error.' return code || 'The site returned an error.'
} }
} }
/**
* First string from an API `errors` blob.
*
* @param {unknown} errors - Check user errors
* @return {string}
*/
function firstUserError(errors) {
if (!errors) {
return ''
}
if (typeof errors === 'string') {
return errors
}
if (Array.isArray(errors)) {
for (const item of errors) {
const text = firstUserError(item)
if (text) {
return text
}
}
return ''
}
if (typeof errors === 'object') {
for (const value of Object.values(errors)) {
const text = firstUserError(value)
if (text) {
return text
}
}
}
return ''
}
/**
* Token, MFA challenge, or a thrown Error from an API auth body.
*
* @param {object} data - parsed JSON
* @param {string} [missingToken] - error when neither token nor mfa is present
* @return {{token?: string, mfa?: object}}
*/
function authResult(data, missingToken) {
if (data.error) {
throw new Error(apiErrorMessage(data.error, data.errors))
}
if (data.mfa && typeof data.mfa === 'object') {
return { mfa: data.mfa }
}
if (!data.token) {
throw new Error(missingToken || 'The site did not return a token.')
}
return { token: data.token }
}
/** /**
* Sign in with username and password. POST api/login. * Sign in with username and password. POST api/login.
* *
* @param {string} siteUrl - canonical site base * @param {string} siteUrl - canonical site base
* @param {string} username - TTP username * @param {string} username - TTP username
* @param {string} password - TTP password * @param {string} password - TTP password
* @return {Promise<string>} - user token * @return {Promise<{token?: string, mfa?: object}>} - token or MFA challenge
*/ */
export async function loginWithPassword(siteUrl, username, password) { export async function loginWithPassword(siteUrl, username, password) {
const data = await ttpRequest({ const data = await ttpRequest({
@ -117,15 +345,76 @@ export async function loginWithPassword(siteUrl, username, password) {
form: { username, password } form: { username, password }
}) })
if (data.error) { if (data.error === 'malformed input' && !data.errors) {
throw new Error(apiErrorMessage(data.error)) throw new Error('Username and password are required.')
} }
if (!data.token) { return authResult(data)
throw new Error('The site did not return a token.')
} }
return data.token /**
* Path for a pending MFA challenge.
*
* @param {string} loginCode - capability id from api/login
* @param {string} [suffix] - extra path (e.g. /reset)
* @return {string}
*/
function mfaPath(loginCode, suffix) {
const base = `/api/login/mfa/${encodeURIComponent(loginCode)}`
return suffix ? `${base}${suffix}` : base
}
/**
* Submit a 6-digit MFA code. POST api/login/mfa/{loginCode}.
*
* @param {string} siteUrl - canonical site base
* @param {string} loginCode - pending challenge id
* @param {string} authCode - submitted code
* @return {Promise<{token?: string, mfa?: object}>}
*/
export async function submitMfaCode(siteUrl, loginCode, authCode) {
const data = await ttpRequest({
siteUrl,
path: mfaPath(loginCode),
method: 'POST',
form: { auth_code: authCode }
})
return authResult(data)
}
/**
* Pick an MFA method. POST api/login/mfa/{loginCode}.
*
* @param {string} siteUrl - canonical site base
* @param {string} loginCode - pending challenge id
* @param {string} method - mfa_phone, mfa_email, or mfa_app
* @return {Promise<{token?: string, mfa?: object}>}
*/
export async function selectMfaMethod(siteUrl, loginCode, method) {
const data = await ttpRequest({
siteUrl,
path: mfaPath(loginCode),
method: 'POST',
form: { mfaMethodSelect: method }
})
return authResult(data)
}
/**
* Clear the chosen MFA method. POST api/login/mfa/{loginCode}/reset.
*
* @param {string} siteUrl - canonical site base
* @param {string} loginCode - pending challenge id
* @return {Promise<{token?: string, mfa?: object}>}
*/
export async function resetMfaMethod(siteUrl, loginCode) {
const data = await ttpRequest({
siteUrl,
path: mfaPath(loginCode, '/reset'),
method: 'POST',
form: { submit: '1' }
})
return authResult(data)
} }
/** /**
@ -160,3 +449,413 @@ export async function findUser(siteUrl, token, idOrUsername) {
export function isDeadTokenError(error) { export function isDeadTokenError(error) {
return error === 'invalid token' || error === 'invalid secret' || error === 'token expired' return error === 'invalid token' || error === 'invalid secret' || error === 'token expired'
} }
/**
* Throw a user-facing Error from an API JSON body when `error` is set.
*
* @param {object} data - parsed JSON
* @return {object} - data when there is no error
*/
export function unwrapApi(data) {
if (data?.error) {
const err = new Error(apiErrorMessage(data.error, data.errors))
if (isDeadTokenError(data.error)) {
err.code = 'DEAD_TOKEN'
}
err.apiError = data.error
throw err
}
return data
}
/**
* Bytes for an avatar sent over IPC.
*
* @param {unknown} data - ArrayBuffer, typed array, Buffer, or serialized Buffer
* @return {Buffer|null}
*/
function avatarBytes(data) {
if (!data) {
return null
}
if (Buffer.isBuffer(data)) {
return data
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data)
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
}
if (data.type === 'Buffer' && Array.isArray(data.data)) {
return Buffer.from(data.data)
}
if (Array.isArray(data)) {
return Buffer.from(data)
}
try {
const bytes = Buffer.from(data)
return bytes.length ? bytes : null
} catch {
return null
}
}
/**
* File/Blob for an avatar sent over IPC.
*
* @param {object} [avatar] - name, type, data (ArrayBuffer or typed array)
* @return {Blob|null}
*/
export function avatarBlob(avatar) {
const bytes = avatarBytes(avatar?.data)
if (!bytes) {
return null
}
const type = avatar.type || 'application/octet-stream'
const name = avatar.name || 'avatar.jpg'
if (typeof File === 'function') {
return new File([bytes], name, { type })
}
return new Blob([bytes], { type })
}
/**
* Current user. GET api/profile.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getProfile(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token })
}
/**
* Save prefs and optional avatar. POST api/profile/update.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {Record<string, string>} fields - form fields
* @param {object} [avatar] - IPC file payload
* @return {Promise<object>}
*/
export async function updateProfile(siteUrl, token, fields, avatar) {
const multipart = { ...(fields || {}) }
const file = avatarBlob(avatar)
if (file) {
const data = new FormData()
Object.entries(multipart).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return
}
data.append(key, value)
})
if (typeof File === 'function' && file instanceof File) {
data.append('avatar', file)
} else {
data.append('avatar', file, avatar.name || 'avatar.jpg')
}
return ttpRequest({
siteUrl,
path: '/api/profile/update',
method: 'POST',
token,
multipart: data
})
}
return ttpRequest({
siteUrl,
path: '/api/profile/update',
method: 'POST',
token,
form: fields
})
}
/**
* Paged notifications. GET api/notifications.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {number} [page=1] - pager page
* @return {Promise<object>}
*/
export async function listNotifications(siteUrl, token, page = 1) {
return ttpRequest({
siteUrl,
path: '/api/notifications',
method: 'GET',
token,
query: { page }
})
}
/**
* Mark a notification read. POST api/notifications/read/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - notification id
* @return {Promise<object>}
*/
export async function readNotification(siteUrl, token, id) {
return ttpRequest({
siteUrl,
path: `/api/notifications/read/${encodeURIComponent(id)}`,
method: 'POST',
token
})
}
/**
* Soft-delete a notification. POST api/notifications/delete/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - notification id
* @return {Promise<object>}
*/
export async function deleteNotification(siteUrl, token, id) {
return ttpRequest({
siteUrl,
path: `/api/notifications/delete/${encodeURIComponent(id)}`,
method: 'POST',
token
})
}
/**
* Paged inbox. GET api/messages.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {number} [page=1] - pager page
* @return {Promise<object>}
*/
export async function listMessages(siteUrl, token, page = 1) {
return ttpRequest({
siteUrl,
path: '/api/messages',
method: 'GET',
token,
query: { page }
})
}
/**
* Recent conversations for the header dropdown. GET api/messages/recent.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {number} [limit=5] - max rows
* @return {Promise<object>}
*/
export async function recentMessages(siteUrl, token, limit = 5) {
return ttpRequest({
siteUrl,
path: '/api/messages/recent',
method: 'GET',
token,
query: { limit }
})
}
/**
* One conversation. GET api/messages/view/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - conversation id
* @param {boolean} [markRead=true] - false sends markRead=0
* @return {Promise<object>}
*/
export async function viewMessage(siteUrl, token, id, markRead = true) {
return ttpRequest({
siteUrl,
path: `/api/messages/view/${encodeURIComponent(id)}`,
method: 'GET',
token,
query: markRead ? undefined : { markRead: 0 }
})
}
/**
* Start a conversation. POST api/messages/create.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string} toUser - username
* @param {string} message - body
* @return {Promise<object>}
*/
export async function createMessage(siteUrl, token, toUser, message) {
return ttpRequest({
siteUrl,
path: '/api/messages/create',
method: 'POST',
token,
form: { toUser, message }
})
}
/**
* Reply in a conversation. POST api/messages/reply/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - conversation id
* @param {string} message - body
* @return {Promise<object>}
*/
export async function replyMessage(siteUrl, token, id, message) {
return ttpRequest({
siteUrl,
path: `/api/messages/reply/${encodeURIComponent(id)}`,
method: 'POST',
token,
form: { message }
})
}
/**
* Mark a conversation read. POST api/messages/read/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - conversation id
* @return {Promise<object>}
*/
export async function readMessage(siteUrl, token, id) {
return ttpRequest({
siteUrl,
path: `/api/messages/read/${encodeURIComponent(id)}`,
method: 'POST',
token
})
}
/**
* Mark a conversation unread. POST api/messages/unread/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - conversation id
* @return {Promise<object>}
*/
export async function unreadMessage(siteUrl, token, id) {
return ttpRequest({
siteUrl,
path: `/api/messages/unread/${encodeURIComponent(id)}`,
method: 'POST',
token
})
}
/**
* Hide a conversation. POST api/messages/delete/{id}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string|number} id - conversation id
* @return {Promise<object>}
*/
export async function deleteMessage(siteUrl, token, id) {
return ttpRequest({
siteUrl,
path: `/api/messages/delete/${encodeURIComponent(id)}`,
method: 'POST',
token
})
}
/**
* Site search. GET api/search.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {object} query - q, resource, page, results
* @return {Promise<object>}
*/
export async function searchSite(siteUrl, token, query) {
return ttpRequest({
siteUrl,
path: '/api/search',
method: 'GET',
token,
query
})
}
/**
* Contact plugin status. GET api/contact.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getContactStatus(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/contact', method: 'GET', token })
}
/**
* Contact form. POST api/contact.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {object} fields - name, entry, optional contactEmail or email
* @return {Promise<object>}
*/
export async function sendContact(siteUrl, token, fields) {
const form = {
name: fields?.name || '',
entry: fields?.entry || ''
}
const email = fields?.contactEmail || fields?.email || ''
if (email) {
form.contactEmail = email
}
return ttpRequest({
siteUrl,
path: '/api/contact',
method: 'POST',
token,
form
})
}
/**
* Bug report plugin status. GET api/bugreport.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getBugreportStatus(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/bugreport', method: 'GET', token })
}
/**
* Bug report. POST api/bugreport.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {object} fields - url, ourl, repeat, entry
* @return {Promise<object>}
*/
export async function sendBugreport(siteUrl, token, fields) {
const url = fields?.url || ''
const ourl = fields?.ourl || url
return ttpRequest({
siteUrl,
path: '/api/bugreport',
method: 'POST',
token,
form: {
url,
ourl,
repeat: fields?.repeat === true || fields?.repeat === 'true' ? 'true' : 'false',
entry: fields?.entry || ''
}
})
}

View File

@ -0,0 +1,93 @@
/** Own update state and retries without exposing updater options to the renderer. */
export function createUpdateController({ updater, version, enabled, 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 timer
let busy = false
let disposed = false
let started = false
const listeners = []
updater.autoDownload = true
// Apply only after the user explicitly chooses Restart to update.
updater.autoInstallOnAppQuit = false
updater.allowPrerelease = false
updater.allowDowngrade = false
function snapshot() {
return { ...state }
}
function publish(patch) {
if (disposed) return
state = { ...state, ...patch }
onState(snapshot())
}
function listen(event, handler) {
updater.on(event, handler)
listeners.push([event, handler])
}
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('download-progress', (progress) => {
const percent = Number.isFinite(progress.percent) ? Math.max(0, Math.min(100, Math.round(progress.percent))) : 0
publish({ status: 'downloading', percent })
})
listen('update-downloaded', (info) => publish({ status: 'ready', version: info.version, percent: 100 }))
listen('error', () => publish({ status: 'error' }))
}
async function check() {
if (!enabled || disposed || busy || ['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' })
} finally {
busy = false
}
return snapshot()
}
function start() {
if (!enabled || disposed || started) return
started = true
const tick = async () => {
await check()
if (!disposed) {
timer = schedule(tick, interval)
timer?.unref?.()
}
}
timer = schedule(tick, firstDelay)
timer?.unref?.()
}
function install() {
if (!enabled || disposed || state.status !== 'ready') return false
publish({ status: 'installing' })
try {
updater.quitAndInstall(true, true)
return true
} catch {
publish({ status: 'error' })
return false
}
}
function dispose() {
disposed = true
cancel(timer)
for (const [event, handler] of listeners) updater.removeListener(event, handler)
}
return { snapshot, check, install, start, dispose }
}

33
src/main/updateIpc.mjs Normal file
View File

@ -0,0 +1,33 @@
/** Accept update commands only from the app's own top-level renderer document. */
export function isTrustedUpdateSender(event, windows, rendererUrl) {
const frame = event.senderFrame
if (!frame || frame !== event.sender.mainFrame ||
!windows.some((window) => !window.isDestroyed() && window.webContents === event.sender)) return false
try {
const actual = new URL(frame.url)
const expected = new URL(rendererUrl)
actual.hash = ''
expected.hash = ''
return actual.href === expected.href
} catch {
return false
}
}
/** Register the three 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:install': () => controller.install()
}
for (const [channel, action] of Object.entries(actions)) {
ipcMain.handle(channel, (event) => {
if (!isTrustedUpdateSender(event, getWindows(), rendererUrl)) throw new Error('Update request denied.')
return action()
})
}
return () => {
for (const channel of Object.keys(actions)) ipcMain.removeHandler(channel)
}
}

36
src/main/updates.js Normal file
View File

@ -0,0 +1,36 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import electronUpdater from 'electron-updater'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createUpdateController } from './updateController.mjs'
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
if (app.isPackaged && process.platform === 'win32') {
const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8'))
enabled = metadata.capsuleUpdates?.enabled === true
}
const { autoUpdater } = electronUpdater
const controller = createUpdateController({
updater: autoUpdater,
version: app.getVersion(),
enabled,
onState(state) {
const windows = BrowserWindow.getAllWindows()
for (const window of windows) {
const sender = window.webContents
if (!sender.isDestroyed() && isTrustedUpdateSender({ sender, senderFrame: sender.mainFrame }, windows, rendererUrl)) {
sender.send('capsule:updates:changed', state)
}
}
}
})
const unregister = registerUpdateIpc({ ipcMain, controller, getWindows: () => BrowserWindow.getAllWindows(), rendererUrl })
controller.start()
app.once('will-quit', () => {
controller.dispose()
unregister()
})
}

View File

@ -1,6 +1,28 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
const capsule = { const capsule = {
/** Read update state without requiring a site session. */
updateStatus() {
return ipcRenderer.invoke('capsule:updates:status')
},
/** Check the release feed baked into the installed application. */
checkForUpdates() {
return ipcRenderer.invoke('capsule:updates:check')
},
/** Install an already verified download and restart Capsule. */
installUpdate() {
return ipcRenderer.invoke('capsule:updates:install')
},
/** Subscribe to public update state; return a listener cleanup function. */
onUpdateStatus(callback) {
const listener = (_event, state) => callback(state)
ipcRenderer.on('capsule:updates:changed', listener)
return () => ipcRenderer.removeListener('capsule:updates:changed', listener)
},
/** /**
* Read the public session. Never includes the token. * Read the public session. Never includes the token.
* *
@ -14,12 +36,50 @@ const capsule = {
* Sign in with a TTP username and password. * Sign in with a TTP username and password.
* *
* @param {object} payload - siteUrl, username, password * @param {object} payload - siteUrl, username, password
* @return {Promise<object>} - public session * @return {Promise<object>} - public session or pending MFA
*/ */
login(payload) { login(payload) {
return ipcRenderer.invoke('capsule:login', payload) return ipcRenderer.invoke('capsule:login', payload)
}, },
/**
* Submit a 6-digit MFA code for the in-memory challenge.
*
* @param {object} payload - authCode
* @return {Promise<object>} - public session or pending MFA
*/
mfaChallenge(payload) {
return ipcRenderer.invoke('capsule:mfaChallenge', payload)
},
/**
* Pick an MFA method for the in-memory challenge.
*
* @param {object} payload - method key
* @return {Promise<object>} - public session or pending MFA
*/
mfaSelect(payload) {
return ipcRenderer.invoke('capsule:mfaSelect', payload)
},
/**
* Clear the chosen MFA method so the picker shows again.
*
* @return {Promise<object>} - pending MFA
*/
mfaReset() {
return ipcRenderer.invoke('capsule:mfaReset')
},
/**
* Drop the in-memory MFA challenge and return to login.
*
* @return {Promise<object>} - public session
*/
mfaCancel() {
return ipcRenderer.invoke('capsule:mfaCancel')
},
/** /**
* Connect with an existing API token from Admin ? Tokens. * Connect with an existing API token from Admin ? Tokens.
* *
@ -46,6 +106,192 @@ const capsule = {
*/ */
logout() { logout() {
return ipcRenderer.invoke('capsule:logout') return ipcRenderer.invoke('capsule:logout')
},
/**
* Profile plus first page of notifications and messages.
*
* @return {Promise<object>}
*/
workspace() {
return ipcRenderer.invoke('capsule:workspace')
},
/**
* Current user. GET api/profile.
*
* @return {Promise<object>}
*/
profile() {
return ipcRenderer.invoke('capsule:profile')
},
/**
* Save prefs and optional avatar. POST api/profile/update.
*
* @param {object} payload - fields, optional avatar { name, type, data }
* @return {Promise<object>}
*/
updateProfile(payload) {
return ipcRenderer.invoke('capsule:updateProfile', payload)
},
/**
* Paged notifications.
*
* @param {object} [payload] - page
* @return {Promise<object>}
*/
notifications(payload) {
return ipcRenderer.invoke('capsule:notifications', payload)
},
/**
* Mark a notification read.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
notificationRead(payload) {
return ipcRenderer.invoke('capsule:notificationRead', payload)
},
/**
* Soft-delete a notification.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
notificationDelete(payload) {
return ipcRenderer.invoke('capsule:notificationDelete', payload)
},
/**
* Paged inbox.
*
* @param {object} [payload] - page
* @return {Promise<object>}
*/
messages(payload) {
return ipcRenderer.invoke('capsule:messages', payload)
},
/**
* Recent conversations for the header dropdown.
*
* @param {object} [payload] - limit
* @return {Promise<object>}
*/
messagesRecent(payload) {
return ipcRenderer.invoke('capsule:messagesRecent', payload)
},
/**
* One conversation thread.
*
* @param {object} payload - id, optional markRead
* @return {Promise<object>}
*/
messageView(payload) {
return ipcRenderer.invoke('capsule:messageView', payload)
},
/**
* Start a conversation.
*
* @param {object} payload - toUser, message
* @return {Promise<object>}
*/
messageCreate(payload) {
return ipcRenderer.invoke('capsule:messageCreate', payload)
},
/**
* Reply in a conversation.
*
* @param {object} payload - id, message
* @return {Promise<object>}
*/
messageReply(payload) {
return ipcRenderer.invoke('capsule:messageReply', payload)
},
/**
* Mark a conversation read.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageRead(payload) {
return ipcRenderer.invoke('capsule:messageRead', payload)
},
/**
* Mark a conversation unread.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageUnread(payload) {
return ipcRenderer.invoke('capsule:messageUnread', payload)
},
/**
* Hide a conversation.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageDelete(payload) {
return ipcRenderer.invoke('capsule:messageDelete', payload)
},
/**
* Site search.
*
* @param {object} payload - q, resource, page
* @return {Promise<object>}
*/
search(payload) {
return ipcRenderer.invoke('capsule:search', payload)
},
/**
* Contact plugin status. GET api/contact.
*
* @return {Promise<object>}
*/
contactStatus() {
return ipcRenderer.invoke('capsule:contactStatus')
},
/**
* Contact form submit.
*
* @param {object} payload - name, entry, contactEmail or email
* @return {Promise<object>}
*/
contact(payload) {
return ipcRenderer.invoke('capsule:contact', payload)
},
/**
* Bug report plugin status. GET api/bugreport.
*
* @return {Promise<object>}
*/
bugreportStatus() {
return ipcRenderer.invoke('capsule:bugreportStatus')
},
/**
* Bug report submit.
*
* @param {object} payload - url, ourl, repeat, entry
* @return {Promise<object>}
*/
bugreport(payload) {
return ipcRenderer.invoke('capsule:bugreport', payload)
} }
} }

View File

@ -1,149 +1,860 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-bs-theme="light">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:;" content="default-src 'self'; style-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src https://cdnjs.cloudflare.com; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: blob: https: http:;"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capsule</title> <title>Capsule</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css"
integrity="sha384-QI8z31KmtR+tk1MYi0DfgxrjYgpTpLLol3bqZA/Q1Y8BvH+6k7/Huoj38gQOaCS7"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="./src/styles.css" /> <link rel="stylesheet" href="./src/styles.css" />
</head> </head>
<body> <body data-shell="login">
<div class="app"> <header class="text-bg-dark">
<header class="topbar"> <div class="capsule-header p-3">
<div class="brand"> <a href="#/" class="capsule-brand text-white text-decoration-none d-flex align-items-center" data-route="home">
<img id="header-logo" class="capsule-logo" width="40" height="40" alt="" hidden />
<span class="brand-mark" aria-hidden="true"></span> <span class="brand-mark" aria-hidden="true"></span>
<div> <span class="brand-name">Capsule</span>
<p class="brand-name">Capsule</p> </a>
<p class="brand-tag">The Tempus Project</p>
<form id="header-search" class="capsule-search" role="search">
<fieldset>
<legend class="visually-hidden">Search the site</legend>
<label class="visually-hidden" for="search-resource">Search in</label>
<label class="visually-hidden" for="search-q">Search terms</label>
<div class="input-group">
<select class="form-select flex-grow-0 w-auto" name="resource" id="search-resource">
<option value="all">All</option>
<option value="posts">Posts</option>
<option value="pages">Pages</option>
</select>
<input
type="search"
class="form-control"
name="q"
id="search-q"
placeholder="Search"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary" aria-label="Search">
<i class="fa fa-fw fa-search" aria-hidden="true"></i>
</button>
</div>
</fieldset>
</form>
<div class="capsule-account">
<div class="dropdown nav-item mx-2">
<a
href="#"
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="notificationsDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="Notifications"
>
<i class="fa fa-fw fa-bell" aria-hidden="true"></i>
<span id="notification-badge" class="badge bg-danger rounded-pill">2</span>
</a>
<ul
id="notification-menu"
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
data-bs-theme="dark"
aria-labelledby="notificationsDropdown"
>
<li><hr class="dropdown-divider" /></li>
<li>
<a href="#/notifications" class="dropdown-item text-center" data-route="notifications">
See All Notifications
</a>
</li>
</ul>
</div>
<div class="dropdown nav-item mx-2">
<a
href="#"
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="messagesDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="Messages"
>
<i class="fa fa-fw fa-envelope" aria-hidden="true"></i>
<span id="message-badge" class="badge bg-danger rounded-pill">1</span>
</a>
<ul
id="message-menu"
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
data-bs-theme="dark"
aria-labelledby="messagesDropdown"
>
<li><hr class="dropdown-divider" /></li>
<li>
<a href="#/messages" class="dropdown-item text-center" data-route="messages">All messages</a>
</li>
</ul>
</div>
<div class="dropdown nav-item mx-2">
<a
href="#"
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="userDropdown"
data-bs-toggle="dropdown"
aria-label="Account menu"
aria-expanded="false"
>
<img
id="header-avatar"
src=""
alt=""
width="32"
height="32"
class="rounded-circle me-2"
hidden
/>
<i id="header-avatar-fallback" class="fa fa-user me-2" aria-hidden="true"></i>
<strong id="header-username">Account</strong>
</a>
<ul
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
data-bs-theme="dark"
>
<li>
<a href="#/profile" class="dropdown-item" data-route="profile">
<i class="fa fa-fw fa-user"></i> Profile
</a>
</li>
<li>
<a href="#/notifications" class="dropdown-item" data-route="notifications">
<i class="fa fa-fw fa-bell"></i> Notifications
</a>
</li>
<li>
<a href="#/messages" class="dropdown-item" data-route="messages">
<i class="fa fa-fw fa-envelope"></i> Messages
</a>
</li>
<li>
<a href="#/settings" class="dropdown-item" data-route="settings">
<i class="fa fa-fw fa-gear"></i> Settings
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<a id="link-email" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
<i class="fa fa-fw fa-envelope"></i> Change email
<i class="fa fa-fw fa-external-link ms-1"></i>
</a>
</li>
<li>
<a id="link-password" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
<i class="fa fa-fw fa-lock"></i> Change password
<i class="fa fa-fw fa-external-link ms-1"></i>
</a>
</li>
<li>
<a id="link-phone" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
<i class="fa fa-fw fa-phone"></i> Phone
<i class="fa fa-fw fa-external-link ms-1"></i>
</a>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button id="logout-button" type="button" class="dropdown-item">
<i class="fa fa-fw fa-power-off"></i> Log Out
</button>
</li>
</ul>
</div> </div>
</div> </div>
<p id="top-status" class="top-status" hidden></p> </div>
<nav class="capsule-mainnav" aria-label="Main">
<ul class="capsule-mainnav-list">
<li><a href="#/game" data-nav="game">Game</a></li>
<li><a href="#/friends" data-nav="friends">Friends</a></li>
<li><a href="#/cabal" data-nav="cabal">Cabal</a></li>
<li><a href="#/hiscores" data-nav="hiscores">HiScores</a></li>
<li><a href="#/shop" data-nav="shop">Shop</a></li>
</ul>
</nav>
</header> </header>
<main class="stage"> <!-- Application updates remain available before sign-in. -->
<section id="view-login" class="view" hidden> <aside id="capsule-updates" class="capsule-updates" aria-label="Capsule updates" hidden>
<div class="card"> <span id="update-status" role="status" aria-live="polite">Loading update status…</span>
<h1>Connect a site</h1> <progress id="update-progress" max="100" value="0" aria-label="Update download" hidden></progress>
<p class="lede"> <button id="update-check" type="button" class="btn btn-sm btn-outline-secondary">Check for updates</button>
Sign in to any Tempus Project install. Capsule talks to that site<74>s API and <button id="update-install" type="button" class="btn btn-sm btn-primary" hidden>Restart to update</button>
keeps the session on this machine. </aside>
</p>
<form id="login-form" class="form"> <main id="main-content">
<label class="field"> <section id="view-login" class="view" hidden>
<span>Site URL</span> <div class="container pt-4">
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
<h1 class="h3 mb-3 text-center">Connect a site</h1>
<p class="text-muted text-center mb-4">
Sign in to any Tempus Project install. Capsule talks to that site?s API and keeps
the session on this machine.
</p>
<form id="login-form">
<div class="mb-3">
<label class="form-label" for="login-site">Site URL</label>
<input <input
id="login-site" id="login-site"
class="form-control"
name="siteUrl" name="siteUrl"
type="url" type="url"
autocomplete="url" autocomplete="url"
placeholder="https://example.com" placeholder="https://example.com"
required required
/> />
</label> </div>
<label class="field"> <div class="mb-3">
<span>Username</span> <label class="form-label" for="login-username">Username</label>
<input id="login-username" name="username" type="text" autocomplete="username" required /> <input
</label> id="login-username"
<label class="field"> class="form-control"
<span>Password</span> name="username"
type="text"
autocomplete="username"
required
/>
</div>
<div class="mb-3">
<label class="form-label" for="login-password">Password</label>
<input <input
id="login-password" id="login-password"
class="form-control"
name="password" name="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
required required
/> />
</label> </div>
<p id="login-error" class="error" hidden></p> <p id="login-error" class="text-danger" hidden></p>
<button id="login-submit" class="btn btn-primary" type="submit">Sign in</button> <button id="login-submit" class="btn btn-primary w-100" type="submit">
<i class="fa fa-fw fa-right-to-bracket"></i> Sign in
</button>
</form> </form>
<details id="token-panel" class="mt-4">
<details id="token-panel" class="token-panel">
<summary>Use an API token instead</summary> <summary>Use an API token instead</summary>
<form id="token-form" class="form"> <form id="token-form" class="mt-3">
<p class="hint"> <p class="small text-muted">
Paste a personal or app token from Admin ? Tokens. Username is optional and Paste a personal or app token from Admin ? Tokens. Username is optional until the
only used to confirm the token against <code>api/users/find</code>. API can identify you.
</p> </p>
<label class="field"> <div class="mb-3">
<span>Site URL</span> <label class="form-label" for="token-site">Site URL</label>
<input <input
id="token-site" id="token-site"
class="form-control"
name="siteUrl" name="siteUrl"
type="url" type="url"
autocomplete="url" autocomplete="url"
placeholder="https://example.com" placeholder="https://example.com"
required required
/> />
</label> </div>
<label class="field"> <div class="mb-3">
<span>API token</span> <label class="form-label" for="token-value">API token</label>
<input id="token-value" name="token" type="password" autocomplete="off" required /> <input id="token-value" class="form-control" name="token" type="password" autocomplete="off" required />
</label> </div>
<label class="field"> <div class="mb-3">
<span>Username <em>(optional)</em></span> <label class="form-label" for="token-username">Username <span class="text-muted">(optional)</span></label>
<input id="token-username" name="username" type="text" autocomplete="username" /> <input id="token-username" class="form-control" name="username" type="text" autocomplete="username" />
</label> </div>
<p id="token-error" class="error" hidden></p> <p id="token-error" class="text-danger" hidden></p>
<button id="token-submit" class="btn btn-secondary" type="submit"> <button id="token-submit" class="btn btn-outline-primary w-100" type="submit">
Connect with token Connect with token
</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>
</section>
<section id="view-mfa" class="view" hidden>
<div class="container pt-4">
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
<h1 class="h3 mb-3 text-center">Verify it's you</h1>
<p id="mfa-prompt" class="text-muted text-center mb-4">
Choose how you want to authenticate.
</p>
<form id="mfa-method-form" hidden>
<fieldset>
<legend class="visually-hidden">Authentication method</legend>
<div id="mfa-methods" class="mb-3"></div>
</fieldset>
<p id="mfa-method-error" class="text-danger" hidden></p>
<button id="mfa-method-submit" class="btn btn-primary w-100" type="submit">
Continue
</button>
</form>
<form id="mfa-code-form" hidden>
<div class="mb-3">
<label class="form-label" for="mfa-code">Authentication code</label>
<input
id="mfa-code"
class="form-control"
name="authCode"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="8"
required
/>
</div>
<p id="mfa-code-error" class="text-danger" hidden></p>
<button id="mfa-code-submit" class="btn btn-primary w-100" type="submit">
Continue
</button>
<button id="mfa-reset" class="btn btn-link w-100 mt-2" type="button" hidden>
Choose another method
</button>
</form>
<p class="text-center mt-4 mb-0">
<button id="mfa-cancel" type="button" class="btn btn-link">Cancel</button>
</p>
</div>
</div>
</section>
<section id="view-stub" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3" id="stub-title"></h1>
</div>
</div> </div>
</section> </section>
<section id="view-home" class="view" hidden> <section id="view-home" class="view" hidden>
<div class="home-grid"> <div class="m-2 m-lg-4">
<article class="card identity"> <div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<p class="eyebrow">Connected</p> <h1 class="h3">Dashboard</h1>
<h1 id="home-username">Signed in</h1> <p class="text-muted" id="home-note"></p>
<p id="home-site" class="site-line"></p> <p class="mb-0">
<dl class="meta"> Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
<div> as <strong id="home-username"></strong>.
<dt>User ID</dt> </p>
<dd id="home-userid"><EFBFBD></dd> <p id="home-plugins" class="small text-muted mb-0"></p>
</div> </div>
<div>
<dt>Auth</dt>
<dd id="home-method"><EFBFBD></dd>
</div> </div>
<div> </section>
<dt>API</dt>
<dd id="home-api"><EFBFBD></dd> <section id="view-contact" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4">
<h1 class="h2 text-center mb-4">Contact Us</h1>
<p id="contact-unavailable" class="alert alert-warning text-center" hidden></p>
<div id="contact-body" hidden>
<div class="col-12 col-lg-6 offset-lg-3">
<p class="text-center text-lg-start">
Here at <strong id="contact-sitename">this site</strong>, we highly value your
feedback. We constantly strive to provide our users with the highest level of
quality in everything we do.
</p>
<p class="text-center text-lg-start">
If you would like to provide any suggestions or comments on our service, we ask
that you please fill out the quick form below and let us know what's on your mind.
</p>
</div> </div>
</dl> <form id="contact-form">
<p id="home-note" class="hint" hidden></p> <div class="mb-3 row">
<div class="actions"> <label for="contact-name" class="col-lg-3 col-form-label text-lg-end">Name:</label>
<button id="logout-button" class="btn btn-secondary" type="button">Sign out</button> <div class="col-lg-6">
<a id="open-site" class="btn btn-ghost" href="#" target="_blank" rel="noreferrer"> <input
Open site id="contact-name"
class="form-control"
name="name"
type="text"
required
maxlength="20"
autocomplete="name"
/>
</div>
</div>
<div class="mb-3 row">
<label for="contact-email" class="col-lg-3 col-form-label text-lg-end">
E-mail: (optional)
</label>
<div class="col-lg-6">
<input
id="contact-email"
class="form-control"
name="contactEmail"
type="email"
autocomplete="email"
/>
</div>
</div>
<div class="mb-3 row">
<label for="contact-entry" class="col-lg-3 col-form-label text-lg-end">Feedback:</label>
<div class="col-lg-6">
<textarea
id="contact-entry"
class="form-control"
name="entry"
rows="6"
maxlength="2000"
required
></textarea>
<small class="form-text text-muted">Max: 2000 characters</small>
</div>
</div>
<p id="contact-status" class="small text-center" hidden></p>
<div class="text-center">
<button class="btn btn-primary btn-lg" type="submit">Submit</button>
</div>
</form>
</div>
</div>
</div>
</section>
<section id="view-bugreport" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h2 text-center mb-4">Report a Bug</h1>
<hr />
<p id="bug-unavailable" class="alert alert-warning text-center" hidden></p>
<div id="bug-body" hidden>
<p class="text-center text-sm-start">
Thank you for visiting our bug reporting page. We value our users' input highly and
in an effort to better serve your needs, please fill out the form below to help us
address this issue.
</p>
<p class="text-center text-sm-start">
We read each and every bug report submitted, and by submitting this form you allow
us to send you a follow-up email.
</p>
<form id="bugreport-form">
<div class="mb-3">
<label class="form-label" for="bug-url">Page you were trying to reach:</label>
<input
id="bug-url"
class="form-control"
name="url"
type="url"
aria-describedby="bug-url-help"
required
/>
<small id="bug-url-help" class="form-text text-muted">
This is the URL of the page you actually received the error.
</small>
</div>
<div class="mb-3">
<label class="form-label" for="bug-ourl">Page you were on:</label>
<input
id="bug-ourl"
class="form-control"
name="ourl"
type="url"
aria-describedby="bug-ourl-help"
/>
<small id="bug-ourl-help" class="form-text text-muted">
This is the URL of the page you were on before you received the error.
</small>
</div>
<div class="mb-3">
<p class="form-label">*Has this happened more than once?</p>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="repeat"
id="bug-repeat-no"
value="false"
checked
/>
<label class="form-check-label" for="bug-repeat-no">No</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="repeat"
id="bug-repeat-yes"
value="true"
/>
<label class="form-check-label" for="bug-repeat-yes">Yes</label>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="bug-entry">Describe the error you received:</label>
<textarea
id="bug-entry"
class="form-control"
name="entry"
rows="6"
maxlength="2000"
aria-describedby="bug-entry-help"
required
></textarea>
<small id="bug-entry-help" class="form-text text-muted">(max: 2000 characters)</small>
</div>
<p id="bug-status" class="small text-center" hidden></p>
<div class="text-center">
<button class="btn btn-primary btn-lg" type="submit">Submit</button>
</div>
</form>
</div>
</div>
</div>
</section>
<section id="view-search" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Search</h1>
<p class="text-muted" id="search-summary"></p>
<p id="search-error" class="text-danger" hidden></p>
<div id="search-results" class="list-group mb-3"></div>
<nav id="search-pager" class="d-flex gap-2" hidden>
<button id="search-prev" type="button" class="btn btn-sm btn-outline-primary">Previous</button>
<span id="search-page-label" class="align-self-center small text-muted"></span>
<button id="search-next" type="button" class="btn btn-sm btn-outline-primary">Next</button>
</nav>
</div>
</div>
</section>
<section id="view-notifications" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3 text-center">Notifications</h1>
<p id="notifications-empty" class="text-muted text-center" hidden></p>
<table class="table">
<tbody id="notification-list"></tbody>
</table>
</div>
</div>
</section>
<section id="view-messages" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<div id="messages-inbox">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Messages</h1>
<a href="#/messages/new" class="btn btn-sm btn-primary" id="messages-new">
<i class="fa fa-fw fa-pen"></i> New message
</a> </a>
</div> </div>
</article> <p id="messages-empty" class="text-muted" hidden></p>
<table class="table table-striped">
<thead>
<tr>
<th>With</th>
<th>Last message</th>
<th>Updated</th>
<th class="visually-hidden">Actions</th>
</tr>
</thead>
<tbody id="message-list"></tbody>
</table>
</div>
<div id="messages-compose" hidden>
<h1 class="h3">New message</h1>
<form id="compose-form">
<div class="mb-3">
<label class="form-label" for="compose-to">Username</label>
<input id="compose-to" class="form-control" name="toUser" type="text" autocomplete="username" required />
</div>
<div class="mb-3">
<label class="form-label" for="compose-body">Message</label>
<textarea id="compose-body" class="form-control" name="message" rows="4" required></textarea>
</div>
<p id="compose-error" class="text-danger" hidden></p>
<button class="btn btn-primary" type="submit">Send</button>
<a href="#/messages" class="btn btn-link">Cancel</a>
</form>
</div>
<div id="messages-thread" hidden>
<button id="thread-back" type="button" class="btn btn-link px-0 mb-2">
<i class="fa fa-fw fa-arrow-left"></i> Inbox
</button>
<h1 class="h3" id="thread-title"></h1>
<p id="thread-error" class="text-danger" hidden></p>
<div id="thread-lines" class="capsule-thread mb-3"></div>
<form id="thread-reply">
<label class="form-label" for="thread-body">Reply</label>
<textarea id="thread-body" class="form-control mb-2" rows="3" required></textarea>
<button class="btn btn-primary" type="submit">Send</button>
</form>
</div>
</div>
</div>
</section>
<article class="card workspace"> <section id="view-profile" class="view" hidden>
<p class="eyebrow">Workspace</p> <div class="m-2 m-lg-4">
<h2>Desktop tools land here</h2> <div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4 text-center">
<p class="lede"> <h1 class="h3 mb-4">Profile</h1>
This area will host the full desktop experience for the plugins enabled on <hr />
the connected site. The current API can sign you in, refresh a token, and <div class="row justify-content-center">
look up a user id. Feature screens wait on those API expansions. <div class="col-md-8">
<div class="card shadow">
<div class="card-header text-center bg-dark text-white">
<h2 class="h3 card-title mb-0" id="profile-name"></h2>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-4 text-center">
<img
id="profile-avatar"
src=""
alt="User Pic"
class="rounded-circle capsule-profile-avatar"
width="200"
height="200"
/>
</div>
<div class="col-md-8">
<table class="table table-borderless text-start">
<tbody>
<tr>
<th scope="row">Registered:</th>
<td id="profile-registered"></td>
</tr>
<tr>
<th scope="row">Last Seen:</th>
<td id="profile-last-login"></td>
</tr>
<tr>
<th scope="row">Gender:</th>
<td id="profile-gender"></td>
</tr>
</tbody>
</table>
<a href="#/settings" class="btn btn-primary btn-sm" data-route="settings">
<i class="fa fa-fw fa-pencil"></i> Edit profile
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="view-settings" class="view" hidden>
<div class="container p-4 context-main-bg mb-4">
<h1 class="h3 mb-4 text-center">Settings</h1>
<hr />
<div class="row justify-content-center">
<div class="col-md-6">
<form id="settings-form">
<fieldset>
<legend class="visually-hidden">Preferences</legend>
<div class="mb-3 text-center">
<img
id="settings-avatar"
src=""
alt="Avatar"
class="rounded-circle img-fluid mb-2"
style="max-width: 125px"
/>
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
</div>
<div class="mb-3">
<label class="form-label" for="settings-gender">Gender</label>
<select id="settings-gender" class="form-select" name="gender">
<option value="unspecified">unspecified</option>
<option value="male">male</option>
<option value="female">female</option>
<option value="other">other</option>
</select>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="settings-newsletter" />
<label class="form-check-label" for="settings-newsletter">Receive our Newsletter?</label>
</div>
<div class="mb-3">
<label class="form-label" for="settings-timezone">Timezone</label>
<select id="settings-timezone" class="form-select" name="timezone"></select>
</div>
<div class="mb-3">
<label class="form-label" for="settings-date">Date Format</label>
<select id="settings-date" class="form-select" name="dateFormat"></select>
</div>
<div class="mb-3">
<label class="form-label" for="settings-time">Time Format</label>
<select id="settings-time" class="form-select" name="timeFormat"></select>
</div>
<div class="mb-3">
<label class="form-label" for="settings-limit">Items Displayed Per Page</label>
<select id="settings-limit" class="form-select" name="pageLimit"></select>
</div>
<div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox" role="switch" id="settings-dark" />
<label class="form-check-label" for="settings-dark">Enable Dark-Mode viewing</label>
</div>
<p id="settings-note" class="small text-muted">
Email, password, and phone stay on the site.
</p> </p>
<ul class="plan"> <p id="settings-status" class="text-success" hidden></p>
<li>Talk to a site of your choice with a user login or an API token</li> <p id="settings-error" class="text-danger" hidden></p>
<li>Keep the token in the OS keychain, not in the renderer</li> <button class="btn btn-lg btn-primary w-100" type="submit">Update</button>
<li>Load plugin-backed tools as the HTTP API grows</li> </fieldset>
</ul> </form>
</article> <div class="alert alert-info mt-4 mb-0">
Email, password, and phone stay on the site.
<div class="d-flex flex-wrap gap-2 mt-2">
<a id="settings-link-email" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
Change email <i class="fa fa-fw fa-external-link"></i>
</a>
<a id="settings-link-password" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
Change password <i class="fa fa-fw fa-external-link"></i>
</a>
<a id="settings-link-phone" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
Phone <i class="fa fa-fw fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
</div> </div>
</section> </section>
</main> </main>
<div class="container mt-auto">
<footer class="pt-4">
<div class="text-center border-top context-main-border">
<button
class="d-md-none my-3 btn btn-lg context-main context-main-border"
type="button"
data-bs-toggle="collapse"
data-bs-target="#footerMenu"
aria-controls="footerMenu"
aria-expanded="false"
aria-label="Toggle footer navigation"
>
<i class="fa fa-bars"></i>
</button>
</div> </div>
<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>
<h2 class="h5">Contact Us</h2>
<ul class="nav flex-column">
<li id="footer-link-contact" class="nav-item mb-2" hidden>
<a href="#/contact" class="nav-link p-0 text-muted">Contact</a>
</li>
<li id="footer-link-bug" class="nav-item mb-2" hidden>
<a href="#/bugreport" class="nav-link p-0 text-muted">Report a Bug</a>
</li>
</ul>
</div>
<div class="capsule-footer-theme">
<h2 class="h5">Dark Mode</h2>
<div class="material-switch px-4 mt-2">
<input name="dark-mode-toggle" type="checkbox" id="dark-mode-toggle" class="form-check-input" />
<label for="dark-mode-toggle" class="label-default"><span class="visually-hidden">Dark mode</span></label>
</div>
</div>
<div class="capsule-footer-info">
<h2 class="h5">More Info</h2>
<ul class="nav flex-column">
<li class="nav-item mb-2">
<a
id="footer-privacy"
href="#"
class="nav-link p-0 text-muted"
target="_blank"
rel="noreferrer"
data-footer-path="home/privacy"
>Privacy Policy</a>
</li>
<li class="nav-item mb-2">
<a
id="footer-terms"
href="#"
class="nav-link p-0 text-muted"
target="_blank"
rel="noreferrer"
data-footer-path="home/terms"
>Terms of Service</a>
</li>
</ul>
</div>
</div>
</div>
<div class="d-flex flex-column flex-md-row justify-content-md-between py-3 border-top context-main-border">
<div class="d-flex justify-content-center justify-content-md-start text-center text-md-start">
<span>
&copy; <span id="footer-year"></span>, Powered by
<a
href="https://thetempusproject.com"
class="text-decoration-none"
target="_blank"
rel="noreferrer"
>The Tempus Project</a>
</span>
</div>
<div class="d-flex justify-content-center justify-content-md-end mt-3 mt-md-0">
<ul class="list-unstyled d-flex mb-0">
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="fb" aria-label="Facebook">
<span class="fa-brands fa-fw fa-facebook" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="twitter" aria-label="X (Twitter)">
<span class="fa-brands fa-fw fa-twitter" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="in" aria-label="LinkedIn">
<span class="fa-brands fa-fw fa-linkedin" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="youtube" aria-label="YouTube">
<span class="fa-brands fa-fw fa-youtube" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="git" aria-label="GitHub">
<span class="fa-brands fa-fw fa-github" aria-hidden="true"></span>
</a>
</li>
</ul>
</div>
</div>
</footer>
</div>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"
></script>
<script type="module" src="./src/main.js"></script> <script type="module" src="./src/main.js"></script>
<script type="module" src="./src/updates.js"></script>
</body> </body>
</html> </html>

99
src/renderer/src/demo.js Normal file
View File

@ -0,0 +1,99 @@
/**
* Placeholder lists until the TTP API grows. Shapes match the live plugins.
*/
export const previewSession = {
connected: true,
preview: true,
siteUrl: 'https://ttp.joeykimsey.com',
username: 'Joey',
userId: 1,
authMethod: 'password',
apiReady: false,
lastSiteUrl: 'https://ttp.joeykimsey.com'
}
export const demoNotifications = [
{
id: 'n1',
unread: true,
createdAt: '2 hours ago',
html: 'Welcome to Capsule. Notifications will load from the site API next.'
},
{
id: 'n2',
unread: false,
createdAt: 'Yesterday',
html: 'Your profile preferences can be edited here. Email, password, and phone stay on the site.'
}
]
export const demoMessages = [
{
id: 'm1',
unread: true,
otherUser: 'Alex',
otherUserPretty: 'Alex',
preview: 'Did the desktop shell pick up the new header?',
lastMessageAt: 'Today'
},
{
id: 'm2',
unread: false,
otherUser: 'Sam',
otherUserPretty: 'Sam',
preview: 'Search should stay in the top middle <20> full width.',
lastMessageAt: 'Monday'
}
]
export const demoProfile = {
username: 'Joey',
usernamePretty: 'Joey',
gender: 'unspecified',
newsletter: true,
timezone: 'America/New_York',
dateFormat: 'F-j-Y',
timeFormat: 'g:i:s A',
pageLimit: '10',
darkMode: false,
registered: 'September 2024',
lastLogin: 'Just now'
}
export const dateFormatOptions = [
{ label: 'January 8, 1991', value: 'F j, Y' },
{ label: 'January 8, 1991 (hyphen)', value: 'F-j-Y' },
{ label: '8 January, 1991', value: 'j-F-Y' },
{ label: 'Jan 8, 1991', value: 'M-j-Y' },
{ label: '1-8-1991', value: 'n-j-Y' },
{ label: '01-08-1991', value: 'm-d-Y' },
{ label: '8-1-1991', value: 'j-n-Y' },
{ label: '08-01-1991', value: 'd-m-Y' }
]
export const timeFormatOptions = [
{ label: '3:33:33 AM', value: 'g:i:s A' },
{ label: '03:33:33 AM', value: 'h:i:s A' },
{ label: '3:33:33 (military)', value: 'G:i:s' },
{ label: '03:33:33 (military)', value: 'H:i:s' }
]
export const timezoneOptions = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Phoenix',
'UTC',
'Europe/London',
'Europe/Paris'
]
export const pageLimitOptions = ['10', '15', '20', '25', '50']
export const searchResources = [
{ value: 'all', label: 'All' },
{ value: 'posts', label: 'Posts' },
{ value: 'pages', label: 'Pages' }
]

File diff suppressed because it is too large Load Diff

View File

@ -1,314 +1,494 @@
/** /**
* Capsule desktop chrome. Tokens follow the TTP brand (logo #3fa9f5, chrome #0c1929). * Capsule chrome. Tokens and header paint follow TTP main.css / main-dark.css.
*/ */
:root { :root {
color-scheme: dark; color-scheme: light;
--canvas: #08111c; --ttp-canvas: #e8eef5;
--chrome: #0c1929; --ttp-surface: #ffffff;
--surface: #122033; --ttp-surface-alt: #f1f5fa;
--surface-alt: #173049; --ttp-surface-muted: #dfe7f0;
--text: #e8eef5; --ttp-surface-inset: #f7f9fc;
--muted: #8aa0b5; --ttp-text: #122033;
--border: #274056; --ttp-text-muted: #5c6e82;
--brand: #3fa9f5; --ttp-border: #d0dbe6;
--primary: #1784c9; --ttp-border-strong: #122033;
--primary-hover: #146ea8; --ttp-link: #1577b8;
--danger: #f07178; --ttp-link-hover: #0f5f94;
--ok: #3dd68c; --ttp-input-bg: #ffffff;
--focus-ring: rgba(63, 169, 245, 0.35); --ttp-input-border: #c5d3e0;
--shadow: 0 18px 48px rgba(0, 0, 0, 0.35); --ttp-input-text: #122033;
--radius: 0.85rem; --ttp-focus: #3fa9f5;
--font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --ttp-focus-ring: rgba(63, 169, 245, 0.35);
--ttp-brand: #3fa9f5;
--ttp-primary: #1784c9;
--ttp-primary-hover: #146ea8;
--ttp-primary-active: #115a8a;
--ttp-primary-rgb: 23, 132, 201;
--ttp-chrome: #0c1929;
--ttp-chrome-rgb: 12, 25, 41;
--ttp-radius: 0.85rem;
--ttp-radius-sm: 0.5rem;
--ttp-shadow: 0 1px 2px rgba(18, 32, 51, 0.05), 0 10px 28px rgba(18, 32, 51, 0.07);
--ttp-shadow-lg: 0 16px 40px rgba(18, 32, 51, 0.14);
--ttp-font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bs-body-color: var(--ttp-text);
--bs-body-bg: var(--ttp-canvas);
--bs-primary: var(--ttp-primary);
--bs-primary-rgb: var(--ttp-primary-rgb);
--bs-dark: var(--ttp-chrome);
--bs-dark-rgb: var(--ttp-chrome-rgb);
--bs-link-color: var(--ttp-link);
--bs-link-hover-color: var(--ttp-link-hover);
--bs-border-color: var(--ttp-border);
--bs-border-radius: var(--ttp-radius);
--bs-body-font-family: var(--ttp-font);
} }
* { html[data-bs-theme='dark'] {
box-sizing: border-box; color-scheme: dark;
--ttp-canvas: #0b1220;
--ttp-surface: #151d2c;
--ttp-surface-alt: #1c2638;
--ttp-surface-muted: #243044;
--ttp-surface-inset: #0f1623;
--ttp-text: #e9eef6;
--ttp-text-muted: #9aaabb;
--ttp-border: #2c3a50;
--ttp-border-strong: #e9eef6;
--ttp-link: #6eb6ff;
--ttp-link-hover: #9ccfff;
--ttp-input-bg: #0f1623;
--ttp-input-border: #3a4b64;
--ttp-input-text: #e9eef6;
--ttp-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 32px rgba(0, 0, 0, 0.28);
--ttp-shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.45);
} }
html, html,
body { body {
margin: 0;
min-height: 100%; min-height: 100%;
background: var(--canvas); margin: 0;
color: var(--text); background: var(--ttp-canvas);
font-family: var(--font); color: var(--ttp-text);
font-family: var(--ttp-font);
} }
body { body {
background:
radial-gradient(900px 420px at 10% -10%, rgba(63, 169, 245, 0.16), transparent 55%),
radial-gradient(700px 360px at 100% 0%, rgba(23, 132, 201, 0.12), transparent 50%),
var(--canvas);
}
.app {
min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh;
} }
.topbar { #main-content {
display: flex; flex: 1 0 auto;
}
.context-main-bg {
background: var(--ttp-surface);
color: var(--ttp-text);
}
.context-main {
color: var(--ttp-text);
}
.context-main-border {
border-color: var(--ttp-border) !important;
}
footer {
flex-shrink: 0;
color: var(--ttp-text-muted);
font-size: 0.925rem;
}
footer a.context-main {
text-decoration: none;
}
/**
* Footer upper band: Contact Us, dark mode, More Info.
*/
.capsule-footer-cols {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
'contact'
'theme'
'info';
gap: 1.5rem;
text-align: center;
}
.capsule-footer-contact {
grid-area: contact;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-areas:
'theme'
'info';
}
.capsule-footer-theme {
grid-area: theme;
}
.capsule-footer-info {
grid-area: info;
}
@media (min-width: 768px) {
.capsule-footer-cols {
grid-template-columns: repeat(3, 1fr);
grid-template-areas: 'contact theme info';
align-items: start;
}
.capsule-footer-contact {
text-align: start;
}
.capsule-footer-theme {
text-align: center;
}
.capsule-footer-info {
text-align: end;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'theme info';
}
}
/**
* Dark-mode footer switch.
*/
.material-switch {
position: relative;
display: inline-block;
width: 50px;
height: 25px;
}
.material-switch input {
opacity: 0;
width: 0;
height: 0;
}
.material-switch .label-default {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--switch-off-bg, var(--ttp-surface-muted));
border-radius: 25px;
transition: background-color 0.3s ease-in-out;
}
.material-switch .label-default::before {
content: '';
position: absolute;
height: 20px;
width: 20px;
border-radius: 50%;
background-color: var(--switch-slider-bg, #fff);
bottom: 2.5px;
left: 5px;
transition: transform 0.3s ease-in-out;
box-shadow: 0 2px 4px #00000033;
}
.material-switch input:checked + .label-default {
background-color: var(--switch-on-bg, var(--ttp-primary));
}
.material-switch input:checked + .label-default::before {
transform: translateX(25px);
}
header.text-bg-dark {
background-color: var(--ttp-chrome) !important;
box-shadow: 0 1px 0 rgba(63, 169, 245, 0.42), 0 10px 28px rgba(12, 25, 41, 0.18);
flex-shrink: 0;
}
.capsule-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
justify-content: space-between;
gap: 1rem; gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid var(--border);
background: rgba(12, 25, 41, 0.86);
} }
.brand { .capsule-brand {
display: flex; gap: 0.65rem;
align-items: center; min-width: 8.5rem;
gap: 0.75rem; }
.capsule-logo {
border-radius: 0.35rem;
} }
.brand-mark { .brand-mark {
width: 2rem; width: 2rem;
height: 1.15rem; height: 1.15rem;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, #7ec8f8, var(--brand) 55%, #1784c9); background: linear-gradient(135deg, #7ec8f8, var(--ttp-brand) 55%, #1784c9);
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12), 0 8px 18px rgba(63, 169, 245, 0.25); box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12);
} }
.brand-name, .capsule-logo:not([hidden]) + .brand-mark {
.brand-tag, display: none;
.eyebrow,
h1,
h2,
p,
dt,
dd,
label span,
button,
summary,
li {
margin: 0;
} }
.brand-name { .brand-name {
font-size: 1rem;
font-weight: 650; font-weight: 650;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.brand-tag, .capsule-search {
.eyebrow, width: 100%;
.hint, justify-self: stretch;
.lede,
.meta dt,
.top-status {
color: var(--muted);
} }
.brand-tag, .capsule-search fieldset {
.eyebrow { border: 0;
font-size: 0.75rem; margin: 0;
letter-spacing: 0.08em; padding: 0;
text-transform: uppercase;
} }
.top-status { .capsule-search .input-group {
font-size: 0.85rem; flex-wrap: nowrap;
border-radius: var(--ttp-radius-sm);
overflow: hidden;
} }
.top-status.is-ok { .capsule-search .input-group > :first-child {
color: var(--ok); border-top-left-radius: var(--ttp-radius-sm);
border-bottom-left-radius: var(--ttp-radius-sm);
} }
.stage { .capsule-search .input-group > :last-child {
flex: 1; border-top-right-radius: var(--ttp-radius-sm);
border-bottom-right-radius: var(--ttp-radius-sm);
}
.capsule-account {
display: flex; display: flex;
align-items: center; align-items: center;
justify-self: end;
}
body[data-shell='login'] .capsule-search,
body[data-shell='login'] .capsule-account,
body[data-shell='login'] .capsule-mainnav {
display: none;
}
.capsule-mainnav {
display: flex;
justify-content: center; justify-content: center;
padding: 1.5rem; padding: 0 1rem 0.85rem;
} }
.view { .capsule-mainnav-list {
width: min(920px, 100%); display: flex;
} flex-wrap: wrap;
.card {
background: rgba(18, 32, 51, 0.92);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.6rem 1.7rem 1.5rem;
}
.card h1,
.card h2 {
margin: 0.35rem 0 0.7rem;
font-size: 1.55rem;
font-weight: 650;
}
.lede {
line-height: 1.55;
margin-bottom: 1.2rem;
}
.form {
display: grid;
gap: 0.85rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field span {
font-size: 0.82rem;
color: var(--muted);
}
.field em {
font-style: normal;
opacity: 0.75;
}
input {
width: 100%;
border: 1px solid var(--border);
background: #0c1929;
color: var(--text);
border-radius: 0.55rem;
padding: 0.65rem 0.75rem;
font: inherit;
}
input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center; justify-content: center;
border: 1px solid transparent; gap: 0.15rem;
border-radius: 0.55rem; list-style: none;
padding: 0.65rem 0.9rem; margin: 0;
font: inherit; padding: 0;
font-weight: 600; }
cursor: pointer;
.capsule-mainnav a {
display: block;
border-radius: 999px;
padding: 0.4rem 0.9rem;
color: rgba(255, 255, 255, 0.88);
text-decoration: none; text-decoration: none;
} }
.btn:disabled { .capsule-mainnav a:hover,
opacity: 0.6; .capsule-mainnav a:focus,
cursor: wait; .capsule-mainnav a.active {
}
.btn-primary {
background: var(--primary);
color: #fff; color: #fff;
background-color: rgba(63, 169, 245, 0.18);
} }
.btn-primary:hover:not(:disabled) { .capsule-mainnav a.active {
background: var(--primary-hover); font-weight: 650;
} }
.btn-secondary { body[data-shell='login'] .capsule-header {
background: var(--surface-alt); grid-template-columns: auto;
color: var(--text); justify-content: start;
border-color: var(--border);
} }
.btn-ghost { header .dropdown {
background: transparent; position: relative;
color: var(--brand); flex-shrink: 0;
border-color: var(--border);
} }
.error { header .dropdown-toggle {
color: var(--danger); outline: 0;
font-size: 0.9rem;
} }
.token-panel { header .dropdown-menu.dropdown-menu-dark {
margin-top: 1.2rem; --bs-dropdown-min-width: 16rem;
border-top: 1px solid var(--border); --bs-dropdown-bg: #0c1929;
padding-top: 0.9rem; --bs-dropdown-color: #fff;
--bs-dropdown-border-color: rgba(255, 255, 255, 0.18);
--bs-dropdown-link-color: rgba(255, 255, 255, 0.9);
--bs-dropdown-link-hover-color: #fff;
--bs-dropdown-link-hover-bg: rgba(63, 169, 245, 0.18);
--bs-dropdown-link-active-bg: var(--ttp-primary);
--bs-dropdown-divider-bg: rgba(255, 255, 255, 0.12);
background-color: #0c1929 !important;
z-index: 1050;
} }
.token-panel summary { header .form-control,
cursor: pointer; header .form-select {
color: var(--brand); background-color: var(--ttp-input-bg);
border-color: var(--ttp-input-border);
color: var(--ttp-input-text);
}
header .form-control:focus,
header .form-select:focus {
border-color: var(--ttp-focus);
box-shadow: 0 0 0 0.2rem var(--ttp-focus-ring);
}
.badge.rounded-pill {
margin-left: 0.15rem;
vertical-align: super;
font-size: 0.65rem;
}
.card {
--bs-card-bg: var(--ttp-surface);
--bs-card-color: var(--ttp-text);
--bs-card-border-color: var(--ttp-border);
--bs-card-border-radius: var(--ttp-radius);
box-shadow: var(--ttp-shadow);
}
.card-header.bg-dark {
background-color: var(--ttp-chrome) !important;
border-bottom: 0;
}
.table {
--bs-table-bg: var(--ttp-surface);
--bs-table-color: var(--ttp-text);
--bs-table-border-color: var(--ttp-border);
--bs-table-striped-bg: var(--ttp-surface-alt);
}
.form-control,
.form-select {
background-color: var(--ttp-input-bg);
color: var(--ttp-input-text);
border-color: var(--ttp-input-border);
border-radius: var(--ttp-radius-sm);
}
.capsule-login-card {
max-width: 28rem;
box-shadow: var(--ttp-shadow-lg);
}
.dropdown-item-block {
display: block;
white-space: normal;
}
.capsule-drop-avatar {
width: 40px;
height: 40px;
object-fit: cover;
flex-shrink: 0;
}
/**
* Profile page avatar.
*/
.capsule-profile-avatar {
width: 200px;
height: 200px;
max-width: 200px;
max-height: 200px;
object-fit: cover;
}
.is-unread {
font-weight: 600; font-weight: 600;
} }
.token-panel .form { .capsule-thread {
margin-top: 0.9rem;
}
.hint {
font-size: 0.88rem;
line-height: 1.5;
}
.hint code {
font-size: 0.84em;
}
.home-grid {
display: grid;
grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr);
gap: 1rem;
}
.identity h1 {
word-break: break-word;
}
.site-line {
color: var(--brand);
margin-bottom: 1rem;
}
.meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.7rem;
margin: 0 0 1rem;
}
.meta dt {
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.meta dd {
margin: 0.2rem 0 0;
font-weight: 600;
}
.actions {
display: flex; display: flex;
flex-direction: column;
gap: 0.65rem;
}
.capsule-bubble {
max-width: 80%;
padding: 0.65rem 0.85rem;
border-radius: var(--ttp-radius-sm);
background: var(--ttp-surface-alt);
}
.capsule-bubble.is-mine {
align-self: flex-end;
background: rgba(var(--ttp-primary-rgb), 0.16);
}
.capsule-row-link {
cursor: pointer;
}
/** Application update status on login and workspace screens. */
.capsule-updates:not([hidden]) {
position: sticky;
top: 0;
z-index: 100;
background: var(--ttp-surface);
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem; gap: 0.75rem;
margin-top: 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--bs-border-color, #ced4da);
font-size: 0.875rem;
} }
.plan { @media (max-width: 900px) {
margin: 0; .capsule-header {
padding-left: 1.1rem; grid-template-columns: auto auto;
color: var(--muted); grid-template-areas:
display: grid; 'brand account'
gap: 0.45rem; 'search search';
} }
.plan li::marker { .capsule-brand {
color: var(--brand); grid-area: brand;
} }
@media (max-width: 800px) { .capsule-account {
.home-grid, grid-area: account;
.meta { }
grid-template-columns: 1fr;
.capsule-search {
grid-area: search;
width: 100%;
} }
} }

View File

@ -0,0 +1,54 @@
const panel = document.getElementById('capsule-updates')
const label = document.getElementById('update-status')
const check = document.getElementById('update-check')
const install = document.getElementById('update-install')
const progress = document.getElementById('update-progress')
/** Render plain-text update state on both the login and workspace screens. */
function renderUpdate(state) {
const messages = {
disabled: 'Local build — automatic updates are disabled.',
idle: 'Updates download automatically. You choose when to restart.',
checking: 'Checking for updates…',
current: 'Capsule is up to date.',
downloading: `Downloading Capsule ${state.version}${state.percent}%`,
ready: `Capsule ${state.version} is ready. Save your work before restarting.`,
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}`
check.hidden = state.status === 'disabled'
check.disabled = ['checking', 'downloading', 'ready', 'installing'].includes(state.status)
install.hidden = state.status !== 'ready'
progress.hidden = state.status !== 'downloading'
progress.value = state.percent || 0
}
if (window.capsule?.updateStatus) {
panel.hidden = false
const unsubscribe = window.capsule.onUpdateStatus(renderUpdate)
window.addEventListener('beforeunload', unsubscribe, { once: true })
window.capsule.updateStatus().then(renderUpdate).catch(() => {
label.textContent = 'Update status is unavailable. Restart Capsule to try again.'
check.disabled = true
})
check.addEventListener('click', async () => {
check.disabled = true
try {
renderUpdate(await window.capsule.checkForUpdates())
} catch {
label.textContent = 'Could not check for updates. Try again.'
check.disabled = false
}
})
install.addEventListener('click', async () => {
install.disabled = true
try {
await window.capsule.installUpdate()
} catch {
label.textContent = 'Could not restart for the update. Please try again.'
} finally {
install.disabled = false
}
})
}

121
tests/releases.test.mjs Normal file
View File

@ -0,0 +1,121 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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, 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' }
test('release builds require explicit HTTPS hosting and signing identity', () => {
assert.throws(() => createBuildConfig({ release: true, env: {} }), /HTTPS/)
assert.throws(() => createBuildConfig({ release: true, env: { CAPSULE_UPDATE_URL: feed } }), /PUBLISHER/)
assert.throws(() => createBuildConfig({ release: true, env: { ...env, CSC_NAME: '' } }), /CSC_LINK/)
for (const url of ['http://host/path/', 'https://u:p@host/path/', 'https://host/path/?token=secret', 'https://host/path/#x', 'https://localhost/', 'https://example.com/', 'https://host/path']) {
assert.throws(() => validateFeedUrl(url))
}
const config = createBuildConfig({ release: true, env })
assert.equal(config.appId, APP_ID)
assert.equal(config.forceCodeSigning, true)
assert.equal(config.win.verifyUpdateCodeSignature, true)
assert.equal(config.win.signtoolOptions.certificateSubjectName, env.CSC_NAME)
assert.equal(config.publish[0].url, feed)
assert.equal(config.extraMetadata.capsuleUpdates.enabled, true)
})
test('local installer has separate identity, no feed, and cannot wipe app data', () => {
const config = createBuildConfig({ env: {} })
assert.notEqual(config.appId, APP_ID)
assert.equal(config.extraMetadata.name, 'capsule-local')
assert.equal(config.extraMetadata.capsuleUpdates.enabled, false)
assert.equal(config.publish, null)
assert.equal(config.nsis.perMachine, false)
assert.equal(config.nsis.deleteAppDataOnUninstall, false)
})
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 }))
const binary = Buffer.from('synthetic installer fixture')
const name = 'Capsule-0.1.1-x64-Setup.exe'
const metadata = { version: '0.1.1', files: [{ url: name, sha512: digest(binary, 'sha512', 'base64'), size: binary.length }] }
await writeFile(join(directory, name), binary)
await writeFile(join(directory, `${name}.blockmap`), 'synthetic blockmap')
await writeFile(join(directory, 'latest.yml'), stringify(metadata))
await assert.rejects(verifyRelease(directory), /ENOENT/)
await writeReleaseManifest(directory, feed)
const verified = await verifyRelease(directory)
assert.deepEqual(verified.files.map((file) => file.name), [name, `${name}.blockmap`, 'latest.yml'])
await writeFile(join(directory, `${name}.blockmap`), 'tampered')
await assert.rejects(verifyRelease(directory), /changed/)
await writeFile(join(directory, name), 'wrong installer')
await assert.rejects(verifyRelease(directory), /checksum/)
metadata.files[0].url = '../../outside.exe'
await writeFile(join(directory, 'latest.yml'), stringify(metadata))
await assert.rejects(verifyRelease(directory), /filename/)
})
test('upload verifies public assets before announcing release and keeps credentials private', async () => {
const files = ['Capsule-0.1.1-x64-Setup.exe', 'Capsule-0.1.1-x64-Setup.exe.blockmap', 'latest.yml']
.map((name) => ({ name, data: Buffer.from(name) }))
const calls = []
await uploadRelease({ feedUrl: feed, files }, {
uploadUrl: 'https://upload.thetempusproject.com/capsule/', token: 'test-token', log: () => {},
fetchImpl: async (url, options) => {
const name = url.pathname.split('/').pop()
calls.push(`${options.method || 'GET'} ${name}`)
assert.equal(options.redirect, 'error')
if (options.method === 'PUT') {
assert.equal(options.headers.Authorization, 'Bearer test-token')
assert.equal(options.headers['If-None-Match'], name === 'latest.yml' ? undefined : '*')
return { ok: true, status: 201 }
}
assert.equal(options.headers, undefined)
return { ok: true, arrayBuffer: async () => files.find((file) => file.name === name).data }
}
})
assert.deepEqual(calls, files.flatMap(({ name }) => [`PUT ${name}`, `GET ${name}`]))
})
test('upload stops before metadata on failed upload or mismatched public artifact', async () => {
const release = { feedUrl: feed, files: [
{ name: 'Capsule-0.1.1-x64-Setup.exe', data: Buffer.from('expected') },
{ name: 'latest.yml', data: Buffer.from('metadata') }
] }
for (const failUpload of [true, false]) {
const calls = []
await assert.rejects(uploadRelease(release, {
uploadUrl: 'https://upload.thetempusproject.com/capsule/', token: 'test-token', log: () => {},
fetchImpl: async (url, options) => {
calls.push(url.pathname)
return options.method === 'PUT'
? { ok: !failUpload, status: failUpload ? 500 : 201 }
: { ok: true, arrayBuffer: async () => Buffer.from('wrong') }
}
}), /failed/)
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)
})

132
tests/updates.test.mjs Normal file
View File

@ -0,0 +1,132 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { createUpdateController } from '../src/main/updateController.mjs'
import { registerUpdateIpc, isTrustedUpdateSender } from '../src/main/updateIpc.mjs'
function fixture(enabled = true) {
const updater = new EventEmitter()
const states = []
const scheduled = []
const cancelled = []
updater.checks = 0
updater.installs = []
updater.checkForUpdates = async () => {
updater.checks++
updater.emit('update-not-available', { version: '0.1.0' })
return {}
}
updater.quitAndInstall = (...args) => updater.installs.push(args)
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 },
cancel: (task) => cancelled.push(task)
})
return { updater, controller, states, scheduled, cancelled }
}
test('local/dev builds never check, schedule, or install', async () => {
const { controller, updater, scheduled } = fixture(false)
controller.start()
await controller.check()
assert.equal(controller.snapshot().status, 'disabled')
assert.equal(updater.checks, 0)
assert.equal(scheduled.length, 0)
assert.equal(controller.install(), false)
})
test('release checks after launch and periodically, with cleanup', async () => {
const { controller, updater, scheduled, cancelled } = fixture()
controller.start()
controller.start()
assert.equal(scheduled.length, 1)
assert.equal(scheduled[0].delay, 15000)
await scheduled[0].callback()
assert.equal(updater.checks, 1)
assert.equal(controller.snapshot().status, 'current')
assert.equal(scheduled[1].delay, 14400000)
controller.dispose()
assert.equal(cancelled[0], scheduled[1])
assert.equal(updater.listenerCount('download-progress'), 0)
})
test('network and download failures are retryable without exposing raw errors', async () => {
const { controller, updater } = fixture()
updater.checkForUpdates = async () => { throw new Error('private endpoint or local path') }
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')) })
await controller.check()
assert.equal(controller.snapshot().status, 'error')
updater.checkForUpdates = async () => {
updater.emit('update-not-available')
return {}
}
await controller.check()
assert.equal(controller.snapshot().status, 'current')
})
test('one in-flight download, progress, verified readiness, and explicit restart', async () => {
const { controller, updater } = fixture()
let finish
updater.checkForUpdates = async () => {
updater.checks++
updater.emit('update-available', { version: '0.1.1' })
return { downloadPromise: new Promise((resolve) => { finish = resolve }) }
}
assert.equal(controller.install(), false)
const pending = controller.check()
await controller.check()
assert.equal(updater.checks, 1)
updater.emit('download-progress', { percent: 53.2 })
assert.equal(controller.snapshot().percent, 53)
assert.equal(controller.install(), false)
updater.emit('update-downloaded', { version: '0.1.1' })
finish()
await pending
assert.equal(updater.autoInstallOnAppQuit, false)
assert.equal(updater.allowDowngrade, false)
assert.equal(updater.allowPrerelease, false)
assert.equal(updater.installs.length, 0)
await controller.check()
assert.equal(updater.checks, 1)
assert.equal(controller.install(), true)
assert.equal(controller.install(), false)
assert.deepEqual(updater.installs, [[true, true]])
})
test('updater error event clears readiness and prevents installation', () => {
const { controller, updater } = fixture()
updater.emit('update-downloaded', { version: '0.1.1' })
updater.emit('error', new Error('verification failed'))
assert.equal(controller.install(), false)
assert.equal(controller.snapshot().status, 'error')
})
test('update IPC rejects sites, child frames, and unowned windows', () => {
const url = 'file:///C:/Capsule/out/renderer/index.html'
const frame = { url: `${url}#/settings` }
const sender = { mainFrame: frame }
const windows = [{ webContents: sender, isDestroyed: () => false }]
const event = { sender, senderFrame: frame }
assert.equal(isTrustedUpdateSender(event, windows, url), true)
assert.equal(isTrustedUpdateSender({ ...event, senderFrame: { url } }, windows, url), false)
assert.equal(isTrustedUpdateSender(event, [], url), false)
frame.url = 'https://ttp.joeykimsey.com/'
assert.equal(isTrustedUpdateSender(event, windows, url), false)
frame.url = `${url}?redirect=other`
assert.equal(isTrustedUpdateSender(event, windows, url), false)
const handlers = new Map()
const { controller } = fixture()
const cleanup = registerUpdateIpc({
ipcMain: { handle: (name, handler) => handlers.set(name, handler), removeHandler: (name) => handlers.delete(name) },
controller, getWindows: () => windows, rendererUrl: url
})
assert.throws(() => handlers.get('capsule:updates:install')(event), /denied/)
frame.url = url
assert.equal(handlers.get('capsule:updates:status')(event).currentVersion, '0.1.0')
cleanup()
assert.equal(handlers.size, 0)
})