Initial commit

This commit is contained in:
Joey Kimsey
2026-09-11 18:14:10 -04:00
commit f785af89cd
15 changed files with 4273 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
out/
dist/
*.log
.DS_Store
Thumbs.db

14
CHANGELOG.md Normal file
View File

@ -0,0 +1,14 @@
# Changelog
All notable changes to Capsule are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- 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.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Joey Kimsey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# Capsule
Desktop companion for [The Tempus Project](https://TheTempusProject.com). Point it at a TTP site, sign in with a username and password or paste an API token, and work against that install from the desktop.
This replaces the old `TempusToolkit` Electron stub. The keepers were the login ? stored-token ? home flow. Hardcoded LAN URLs, `nodeIntegration`, and the leftover WoW-folder picker did not come along.
## Run it
From this folder (Windows source checkout is fine <20> Capsule is Node, not PHP):
```bash
npm install
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.
## 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<54>s same-origin CORS policy does not apply.
| 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. |
| Confirm identity | `GET /api/users/find/{username}` | Bearer token. Returns a user id only. |
| Existing token | Admin ? Tokens | Personal or app token. Username is optional and only used for that find call. |
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available.
App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
## Layout
| Path | Job |
|------|-----|
| `src/main/` | Window, session file, TTP HTTP, IPC |
| `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login view and connected workspace |
## Remote
This checkout starts with no git remote. When the repo exists on the project host:
`ssh://git@git.thetempusproject.com:2222/the-tempus-project/capsule.git`

7
electron.vite.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'electron-vite'
export default defineConfig({
main: {},
preload: {},
renderer: {}
})

3008
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "capsule",
"version": "0.1.0",
"description": "Desktop companion for The Tempus Project sites",
"main": "./out/main/index.js",
"author": "Joey Kimsey <Joey@thetempusproject.com>",
"homepage": "https://TheTempusProject.com",
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
"start": "electron-vite preview",
"build": "electron-vite build"
},
"dependencies": {
"@electron-toolkit/utils": "^4.0.0"
},
"devDependencies": {
"electron": "^39.2.6",
"electron-vite": "^5.0.0",
"vite": "^7.2.6"
}
}

64
src/main/index.js Normal file
View File

@ -0,0 +1,64 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { registerSessionIpc } from './sessionIpc.js'
/**
* Open the main Capsule window.
*
* @return {void}
*/
function createWindow() {
const mainWindow = new BrowserWindow({
width: 920,
height: 640,
minWidth: 760,
minHeight: 520,
show: false,
autoHideMenuBar: true,
title: 'Capsule',
backgroundColor: '#0c1929',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.thetempusproject.capsule')
app.on('browser-window-created', (_event, window) => {
optimizer.watchWindowShortcuts(window)
})
registerSessionIpc()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

126
src/main/sessionIpc.js Normal file
View File

@ -0,0 +1,126 @@
/**
* IPC handlers for login, token connect, logout, and session reads.
*/
import { ipcMain } from 'electron'
import { findUser, isDeadTokenError, loginWithPassword, normalizeSiteUrl } from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
/**
* Build a stored session after a successful auth.
*
* @param {object} fields - connection fields
* @param {string} fields.siteUrl - canonical site base
* @param {string} fields.token - user or app token
* @param {string} [fields.username] - username used to sign in
* @param {string} fields.authMethod - password or token
* @return {Promise<object>} - public session
*/
async function persistConnection(fields) {
const siteUrl = fields.siteUrl
const token = fields.token
const username = String(fields.username || '').trim()
const authMethod = fields.authMethod
let userId = null
let apiReady = false
if (username) {
try {
const found = await findUser(siteUrl, token, username)
if (isDeadTokenError(found.error)) {
const dead = new Error(
found.error === 'token expired' ? 'That API token has expired.' : 'That API token was not accepted.'
)
dead.code = 'DEAD_TOKEN'
throw dead
}
userId = found.userId
apiReady = found.userId !== null
} catch (err) {
if (err?.code === 'DEAD_TOKEN') {
throw err
}
apiReady = false
}
}
const session = {
siteUrl,
lastSiteUrl: siteUrl,
username,
userId,
authMethod,
apiReady,
token
}
writeSession(session)
return publicSession(session)
}
/**
* Register session IPC. Call once after app ready.
*
* @return {void}
*/
export function registerSessionIpc() {
ipcMain.handle('capsule:session', () => {
return publicSession(readSession())
})
ipcMain.handle('capsule:login', async (_event, payload) => {
const siteUrl = normalizeSiteUrl(payload?.siteUrl)
const username = String(payload?.username || '').trim()
const password = String(payload?.password || '')
if (!username || !password) {
throw new Error('Username and password are required.')
}
const token = await loginWithPassword(siteUrl, username, password)
return persistConnection({ siteUrl, token, username, authMethod: 'password' })
})
ipcMain.handle('capsule:connectToken', async (_event, payload) => {
const siteUrl = normalizeSiteUrl(payload?.siteUrl)
const token = String(payload?.token || '').trim()
const username = String(payload?.username || '').trim()
if (!token) {
throw new Error('Paste an API token.')
}
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
})
ipcMain.handle('capsule:verify', async () => {
const session = readSession()
if (!session?.token || !session.siteUrl) {
return publicSession(session)
}
if (!session.username) {
return publicSession(session)
}
try {
const found = await findUser(session.siteUrl, session.token, session.username)
if (isDeadTokenError(found.error)) {
writeLastSite(session.siteUrl)
return publicSession(readSession())
}
session.userId = found.userId
session.apiReady = found.userId !== null
writeSession(session)
return publicSession(session)
} catch {
return publicSession(session)
}
})
ipcMain.handle('capsule:logout', () => {
const session = readSession()
writeLastSite(session?.siteUrl || session?.lastSiteUrl || '')
return publicSession(readSession())
})
}

118
src/main/sessionStore.js Normal file
View File

@ -0,0 +1,118 @@
/**
* Persist the connected TTP site and token under Electron userData.
* The token is encrypted with safeStorage when the OS keychain is available.
*/
import { app, safeStorage } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
/**
* Path to the session file in userData.
*
* @return {string} - absolute path
*/
export function sessionFilePath() {
return join(app.getPath('userData'), 'session.json')
}
/**
* Read the raw session object from disk. Token is decrypted into memory.
*
* @return {object|null} - session, or null when none is stored
*/
export function readSession() {
const file = sessionFilePath()
if (!existsSync(file)) {
return null
}
let parsed
try {
parsed = JSON.parse(readFileSync(file, 'utf8'))
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
return null
}
const session = { ...parsed }
if (session.tokenEnc && safeStorage.isEncryptionAvailable()) {
try {
session.token = safeStorage.decryptString(Buffer.from(session.tokenEnc, 'base64'))
} catch {
session.token = ''
}
}
return session
}
/**
* Write a session. Encrypts the token when safeStorage is available.
*
* @param {object} session - fields to persist
* @return {void}
*/
export function writeSession(session) {
const file = sessionFilePath()
mkdirSync(dirname(file), { recursive: true })
const payload = { ...session }
const token = payload.token
delete payload.token
delete payload.tokenEnc
if (token) {
if (safeStorage.isEncryptionAvailable()) {
payload.tokenEnc = safeStorage.encryptString(token).toString('base64')
} else {
payload.token = token
}
}
writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
}
/**
* Keep the last site URL after logout so the login form can refill it.
*
* @param {string} lastSiteUrl - canonical site base
* @return {void}
*/
export function writeLastSite(lastSiteUrl) {
writeSession({ lastSiteUrl })
}
/**
* Session fields the renderer may see. Never includes the token.
*
* @param {object|null} session - stored session
* @return {object} - public connection state
*/
export function publicSession(session) {
if (!session) {
return {
connected: false,
siteUrl: '',
username: '',
userId: null,
authMethod: '',
apiReady: false,
lastSiteUrl: ''
}
}
const token = session.token || ''
return {
connected: Boolean(token),
siteUrl: session.siteUrl || '',
username: session.username || '',
userId: session.userId ?? null,
authMethod: session.authMethod || '',
apiReady: Boolean(session.apiReady),
lastSiteUrl: session.lastSiteUrl || session.siteUrl || ''
}
}

162
src/main/ttpClient.js Normal file
View File

@ -0,0 +1,162 @@
/**
* HTTP calls to a TTP site. Runs in the main process so CORS does not apply.
*/
/**
* Normalize a TTP site URL to scheme + host + optional path, no trailing slash.
*
* @param {string} raw - what the user typed
* @return {string} - canonical site base
*/
export function normalizeSiteUrl(raw) {
const trimmed = String(raw ?? '').trim()
if (!trimmed) {
throw new Error('Enter the site URL.')
}
let parsed
try {
parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`)
} catch {
throw new Error('Enter a valid site URL.')
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Site URL must start with http:// or https://.')
}
const path = parsed.pathname.replace(/\/+$/, '')
return `${parsed.origin}${path === '/' ? '' : path}`
}
/**
* Call a TTP API path and parse JSON.
*
* @param {object} options - request
* @param {string} options.siteUrl - canonical site base
* @param {string} options.path - path starting with /api/
* @param {string} [options.method='GET'] - HTTP method
* @param {string} [options.token] - Bearer token
* @param {Record<string, string>} [options.form] - urlencoded body
* @return {Promise<object>} - parsed JSON
*/
export async function ttpRequest(options) {
const siteUrl = options.siteUrl
const path = options.path
const method = options.method || 'GET'
const token = options.token
const form = options.form
const url = `${siteUrl}${path}`
const headers = { Accept: 'application/json' }
let body
if (token) {
headers.Authorization = `Bearer ${token}`
}
if (form) {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(form).toString()
}
let response
try {
response = await fetch(url, { method, headers, body, redirect: 'follow' })
} catch {
throw new Error('Could not reach that site.')
}
const text = await response.text()
let data
try {
data = JSON.parse(text)
} catch {
throw new Error(`The site did not return API JSON (${response.status}).`)
}
return data
}
/**
* Map a TTP API error string to a short user-facing line.
*
* @param {string} code - API `error` value
* @return {string} - message for the login form
*/
export function apiErrorMessage(code) {
switch (code) {
case 'malformed input':
return 'Username and password are required.'
case 'bad credentials':
return 'Those credentials were not accepted.'
case 'invalid token':
case 'invalid secret':
return 'That API token was not accepted.'
case 'token expired':
return 'That API token has expired.'
case 'IRDK':
return 'The site could not refresh this token.'
default:
return code || 'The site returned an error.'
}
}
/**
* Sign in with username and password. POST api/login.
*
* @param {string} siteUrl - canonical site base
* @param {string} username - TTP username
* @param {string} password - TTP password
* @return {Promise<string>} - user token
*/
export async function loginWithPassword(siteUrl, username, password) {
const data = await ttpRequest({
siteUrl,
path: '/api/login',
method: 'POST',
form: { username, password }
})
if (data.error) {
throw new Error(apiErrorMessage(data.error))
}
if (!data.token) {
throw new Error('The site did not return a token.')
}
return data.token
}
/**
* Look up a user id. GET api/users/find/{id|username}.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @param {string} idOrUsername - user id or username
* @return {Promise<{userId: number|string|null, error: string}>} - id on success
*/
export async function findUser(siteUrl, token, idOrUsername) {
const path = `/api/users/find/${encodeURIComponent(idOrUsername)}`
const data = await ttpRequest({ siteUrl, path, method: 'GET', token })
if (data.error) {
return { userId: null, error: String(data.error) }
}
if (data.data == null) {
return { userId: null, error: 'No user found.' }
}
return { userId: data.data, error: '' }
}
/**
* True when the API rejected the stored Bearer token itself.
*
* @param {string} error - API `error` value
* @return {boolean} - true when the session should be cleared
*/
export function isDeadTokenError(error) {
return error === 'invalid token' || error === 'invalid secret' || error === 'token expired'
}

52
src/preload/index.js Normal file
View File

@ -0,0 +1,52 @@
import { contextBridge, ipcRenderer } from 'electron'
const capsule = {
/**
* Read the public session. Never includes the token.
*
* @return {Promise<object>} - connection state
*/
session() {
return ipcRenderer.invoke('capsule:session')
},
/**
* Sign in with a TTP username and password.
*
* @param {object} payload - siteUrl, username, password
* @return {Promise<object>} - public session
*/
login(payload) {
return ipcRenderer.invoke('capsule:login', payload)
},
/**
* Connect with an existing API token from Admin ? Tokens.
*
* @param {object} payload - siteUrl, token, optional username
* @return {Promise<object>} - public session
*/
connectToken(payload) {
return ipcRenderer.invoke('capsule:connectToken', payload)
},
/**
* Recheck a stored token against api/users/find.
*
* @return {Promise<object>} - public session
*/
verify() {
return ipcRenderer.invoke('capsule:verify')
},
/**
* Clear the stored token and return to the login view.
*
* @return {Promise<object>} - public session
*/
logout() {
return ipcRenderer.invoke('capsule:logout')
}
}
contextBridge.exposeInMainWorld('capsule', capsule)

149
src/renderer/index.html Normal file
View File

@ -0,0 +1,149 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capsule</title>
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div>
<p class="brand-name">Capsule</p>
<p class="brand-tag">The Tempus Project</p>
</div>
</div>
<p id="top-status" class="top-status" hidden></p>
</header>
<main class="stage">
<section id="view-login" class="view" hidden>
<div class="card">
<h1>Connect a site</h1>
<p class="lede">
Sign in to any Tempus Project install. Capsule talks to that site<74>s API and
keeps the session on this machine.
</p>
<form id="login-form" class="form">
<label class="field">
<span>Site URL</span>
<input
id="login-site"
name="siteUrl"
type="url"
autocomplete="url"
placeholder="https://example.com"
required
/>
</label>
<label class="field">
<span>Username</span>
<input id="login-username" name="username" type="text" autocomplete="username" required />
</label>
<label class="field">
<span>Password</span>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<p id="login-error" class="error" hidden></p>
<button id="login-submit" class="btn btn-primary" type="submit">Sign in</button>
</form>
<details id="token-panel" class="token-panel">
<summary>Use an API token instead</summary>
<form id="token-form" class="form">
<p class="hint">
Paste a personal or app token from Admin ? Tokens. Username is optional and
only used to confirm the token against <code>api/users/find</code>.
</p>
<label class="field">
<span>Site URL</span>
<input
id="token-site"
name="siteUrl"
type="url"
autocomplete="url"
placeholder="https://example.com"
required
/>
</label>
<label class="field">
<span>API token</span>
<input id="token-value" name="token" type="password" autocomplete="off" required />
</label>
<label class="field">
<span>Username <em>(optional)</em></span>
<input id="token-username" name="username" type="text" autocomplete="username" />
</label>
<p id="token-error" class="error" hidden></p>
<button id="token-submit" class="btn btn-secondary" type="submit">
Connect with token
</button>
</form>
</details>
</div>
</section>
<section id="view-home" class="view" hidden>
<div class="home-grid">
<article class="card identity">
<p class="eyebrow">Connected</p>
<h1 id="home-username">Signed in</h1>
<p id="home-site" class="site-line"></p>
<dl class="meta">
<div>
<dt>User ID</dt>
<dd id="home-userid"><EFBFBD></dd>
</div>
<div>
<dt>Auth</dt>
<dd id="home-method"><EFBFBD></dd>
</div>
<div>
<dt>API</dt>
<dd id="home-api"><EFBFBD></dd>
</div>
</dl>
<p id="home-note" class="hint" hidden></p>
<div class="actions">
<button id="logout-button" class="btn btn-secondary" type="button">Sign out</button>
<a id="open-site" class="btn btn-ghost" href="#" target="_blank" rel="noreferrer">
Open site
</a>
</div>
</article>
<article class="card workspace">
<p class="eyebrow">Workspace</p>
<h2>Desktop tools land here</h2>
<p class="lede">
This area will host the full desktop experience for the plugins enabled on
the connected site. The current API can sign you in, refresh a token, and
look up a user id. Feature screens wait on those API expansions.
</p>
<ul class="plan">
<li>Talk to a site of your choice with a user login or an API token</li>
<li>Keep the token in the OS keychain, not in the renderer</li>
<li>Load plugin-backed tools as the HTTP API grows</li>
</ul>
</article>
</div>
</section>
</main>
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>

166
src/renderer/src/main.js Normal file
View File

@ -0,0 +1,166 @@
/**
* Login and home views. The token never enters this process.
*/
const loginView = document.getElementById('view-login')
const homeView = document.getElementById('view-home')
const topStatus = document.getElementById('top-status')
const loginForm = document.getElementById('login-form')
const loginError = document.getElementById('login-error')
const loginSubmit = document.getElementById('login-submit')
const loginSite = document.getElementById('login-site')
const loginUsername = document.getElementById('login-username')
const tokenForm = document.getElementById('token-form')
const tokenError = document.getElementById('token-error')
const tokenSubmit = document.getElementById('token-submit')
const tokenSite = document.getElementById('token-site')
const tokenUsername = document.getElementById('token-username')
const homeUsername = document.getElementById('home-username')
const homeSite = document.getElementById('home-site')
const homeUserId = document.getElementById('home-userid')
const homeMethod = document.getElementById('home-method')
const homeApi = document.getElementById('home-api')
const homeNote = document.getElementById('home-note')
const openSite = document.getElementById('open-site')
const logoutButton = document.getElementById('logout-button')
/**
* Show or hide a status/error line.
*
* @param {HTMLElement} el - message node
* @param {string} [message] - text to show; empty hides the node
* @return {void}
*/
function setMessage(el, message) {
const text = String(message || '')
el.hidden = text === ''
el.textContent = text
}
/**
* Fill both site URL fields from the last used host.
*
* @param {string} siteUrl - canonical site base
* @return {void}
*/
function fillSiteFields(siteUrl) {
if (!siteUrl) {
return
}
loginSite.value = siteUrl
tokenSite.value = siteUrl
}
/**
* Render the login or home view from a public session.
*
* @param {object} session - connection state from main
* @return {void}
*/
function render(session) {
const connected = Boolean(session?.connected)
loginView.hidden = connected
homeView.hidden = !connected
fillSiteFields(session?.lastSiteUrl || session?.siteUrl || '')
if (!connected) {
topStatus.hidden = true
return
}
const username = session.username || 'API token'
homeUsername.textContent = session.username ? username : 'Signed in with token'
homeSite.textContent = session.siteUrl
homeUserId.textContent = session.userId == null ? '<27>' : String(session.userId)
homeMethod.textContent = session.authMethod === 'token' ? 'API token' : 'Password'
homeApi.textContent = session.apiReady ? 'Ready' : 'Limited'
openSite.href = session.siteUrl
topStatus.hidden = false
topStatus.classList.add('is-ok')
topStatus.textContent = 'Connected'
if (session.apiReady) {
setMessage(homeNote, '')
} else {
setMessage(
homeNote,
'Signed in, but api/users/find did not confirm this user. Turn on personal API access on the site, or add a username when connecting with a token.'
)
}
}
/**
* Run an auth IPC call and render the result or an error.
*
* @param {HTMLButtonElement} button - submit button to disable
* @param {HTMLElement} errorEl - error line
* @param {() => Promise<object>} work - IPC call
* @return {Promise<void>}
*/
async function runAuth(button, errorEl, work) {
button.disabled = true
setMessage(errorEl, '')
try {
render(await work())
} catch (err) {
setMessage(errorEl, err?.message || 'Sign-in failed.')
} finally {
button.disabled = false
}
}
loginForm.addEventListener('submit', (event) => {
event.preventDefault()
runAuth(loginSubmit, loginError, () =>
window.capsule.login({
siteUrl: loginSite.value,
username: loginUsername.value,
password: document.getElementById('login-password').value
})
)
})
tokenForm.addEventListener('submit', (event) => {
event.preventDefault()
runAuth(tokenSubmit, tokenError, () =>
window.capsule.connectToken({
siteUrl: tokenSite.value,
token: document.getElementById('token-value').value,
username: tokenUsername.value
})
)
})
logoutButton.addEventListener('click', async () => {
render(await window.capsule.logout())
})
openSite.addEventListener('click', (event) => {
const href = openSite.getAttribute('href')
if (!href || href === '#') {
event.preventDefault()
}
})
/**
* Load any stored session, then confirm it against the site when possible.
*
* @return {Promise<void>}
*/
async function boot() {
if (!window.capsule) {
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
loginView.hidden = false
return
}
const session = await window.capsule.session()
if (session.connected) {
render(await window.capsule.verify())
return
}
render(session)
}
boot()

314
src/renderer/src/styles.css Normal file
View File

@ -0,0 +1,314 @@
/**
* Capsule desktop chrome. Tokens follow the TTP brand (logo #3fa9f5, chrome #0c1929).
*/
:root {
color-scheme: dark;
--canvas: #08111c;
--chrome: #0c1929;
--surface: #122033;
--surface-alt: #173049;
--text: #e8eef5;
--muted: #8aa0b5;
--border: #274056;
--brand: #3fa9f5;
--primary: #1784c9;
--primary-hover: #146ea8;
--danger: #f07178;
--ok: #3dd68c;
--focus-ring: rgba(63, 169, 245, 0.35);
--shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
--radius: 0.85rem;
--font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--canvas);
color: var(--text);
font-family: var(--font);
}
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;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid var(--border);
background: rgba(12, 25, 41, 0.86);
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-mark {
width: 2rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(135deg, #7ec8f8, var(--brand) 55%, #1784c9);
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12), 0 8px 18px rgba(63, 169, 245, 0.25);
}
.brand-name,
.brand-tag,
.eyebrow,
h1,
h2,
p,
dt,
dd,
label span,
button,
summary,
li {
margin: 0;
}
.brand-name {
font-size: 1rem;
font-weight: 650;
letter-spacing: 0.02em;
}
.brand-tag,
.eyebrow,
.hint,
.lede,
.meta dt,
.top-status {
color: var(--muted);
}
.brand-tag,
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.top-status {
font-size: 0.85rem;
}
.top-status.is-ok {
color: var(--ok);
}
.stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.view {
width: min(920px, 100%);
}
.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;
border: 1px solid transparent;
border-radius: 0.55rem;
padding: 0.65rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: wait;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--surface-alt);
color: var(--text);
border-color: var(--border);
}
.btn-ghost {
background: transparent;
color: var(--brand);
border-color: var(--border);
}
.error {
color: var(--danger);
font-size: 0.9rem;
}
.token-panel {
margin-top: 1.2rem;
border-top: 1px solid var(--border);
padding-top: 0.9rem;
}
.token-panel summary {
cursor: pointer;
color: var(--brand);
font-weight: 600;
}
.token-panel .form {
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;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1rem;
}
.plan {
margin: 0;
padding-left: 1.1rem;
color: var(--muted);
display: grid;
gap: 0.45rem;
}
.plan li::marker {
color: var(--brand);
}
@media (max-width: 800px) {
.home-grid,
.meta {
grid-template-columns: 1fr;
}
}