feat: integrate all production readiness modules

Register new commands in lib.rs, add command modules, update Cargo deps
(notify-rust, keyring, bundled-full), fix PRAGMA WAL for bundled-full,
add notifications/heartbeats/FTS5 indexing to agent-dispatcher,
update SettingsTab with secrets/plugins/sandbox/updates sections.
This commit is contained in:
Hibryda 2026-03-12 04:57:29 +01:00 committed by DexterFromLab
parent 66cbee2c53
commit afc059b346
9 changed files with 1377 additions and 20 deletions

View file

@ -14,7 +14,8 @@ import {
getAgentSessions,
getAgentSession,
} from './stores/agents.svelte';
import { notify } from './stores/notifications.svelte';
import { notify, addNotification } from './stores/notifications.svelte';
import { classifyError } from './utils/error-classifier';
import { tel } from './adapters/telemetry-bridge';
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
@ -35,6 +36,10 @@ import {
spawnSubagentPane,
clearSubagentRoutes,
} from './utils/subagent-router';
import { indexMessage } from './adapters/search-bridge';
import { recordHeartbeat } from './adapters/btmsg-bridge';
import { logAuditEvent } from './adapters/audit-bridge';
import type { AgentId } from './types/ids';
// Re-export public API consumed by other modules
export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence';
@ -72,11 +77,20 @@ export async function startAgentDispatcher(): Promise<void> {
if (!msg.sessionId) return;
const sessionId = SessionId(msg.sessionId);
// Record heartbeat on any agent activity (best-effort, fire-and-forget)
const hbProjectId = getSessionProjectId(sessionId);
if (hbProjectId) {
recordHeartbeat(hbProjectId as unknown as AgentId).catch(() => {});
}
switch (msg.type) {
case 'agent_started':
updateAgentStatus(sessionId, 'running');
recordSessionStart(sessionId);
tel.info('agent_started', { sessionId });
if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(() => {});
}
break;
case 'agent_event':
@ -87,13 +101,39 @@ export async function startAgentDispatcher(): Promise<void> {
updateAgentStatus(sessionId, 'done');
tel.info('agent_stopped', { sessionId });
notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(() => {});
}
break;
case 'agent_error':
updateAgentStatus(sessionId, 'error', msg.message);
tel.error('agent_error', { sessionId, error: msg.message });
notify('error', `Agent error: ${msg.message ?? 'Unknown'}`);
case 'agent_error': {
const errorMsg = msg.message ?? 'Unknown';
const classified = classifyError(errorMsg);
updateAgentStatus(sessionId, 'error', errorMsg);
tel.error('agent_error', { sessionId, error: errorMsg, errorType: classified.type });
// Show type-specific toast
if (classified.type === 'rate_limit') {
notify('warning', `Rate limited. ${classified.retryDelaySec > 0 ? `Retrying in ~${classified.retryDelaySec}s...` : ''}`);
} else if (classified.type === 'auth') {
notify('error', 'API key invalid or expired. Check Settings.');
} else if (classified.type === 'quota') {
notify('error', 'API quota exceeded. Check your billing.');
} else if (classified.type === 'overloaded') {
notify('warning', 'API overloaded. Will retry shortly...');
} else if (classified.type === 'network') {
notify('error', 'Network error. Check your connection.');
} else {
notify('error', `Agent error: ${errorMsg}`);
}
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(() => {});
}
break;
}
case 'agent_log':
break;
@ -121,6 +161,7 @@ export async function startAgentDispatcher(): Promise<void> {
restartAttempts++;
const delayMs = 1000 * Math.pow(2, restartAttempts - 1); // 1s, 2s, 4s
notify('warning', `Sidecar crashed, restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})...`);
addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system');
await new Promise((resolve) => setTimeout(resolve, delayMs));
try {
await restartAgent();
@ -234,8 +275,19 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
isError: cost.isError,
});
if (cost.isError) {
updateAgentStatus(sessionId, 'error', cost.errors?.join('; '));
notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`);
const costErrorMsg = cost.errors?.join('; ') ?? 'Unknown error';
const costClassified = classifyError(costErrorMsg);
updateAgentStatus(sessionId, 'error', costErrorMsg);
if (costClassified.type === 'rate_limit') {
notify('warning', `Rate limited. ${costClassified.retryDelaySec > 0 ? `Retrying in ~${costClassified.retryDelaySec}s...` : ''}`);
} else if (costClassified.type === 'auth') {
notify('error', 'API key invalid or expired. Check Settings.');
} else if (costClassified.type === 'quota') {
notify('error', 'API quota exceeded. Check your billing.');
} else {
notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`);
}
} else {
updateAgentStatus(sessionId, 'done');
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
@ -264,6 +316,13 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
else recordActivity(actProjId);
}
appendAgentMessages(sessionId, mainMessages);
// Index searchable text content into FTS5 search database
for (const msg of mainMessages) {
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
indexMessage(sessionId, 'assistant', msg.content).catch(() => {});
}
}
}
// Append messages to child panes and update their status

View file

@ -25,6 +25,22 @@
import type { ProviderId, ProviderSettings } from '../../providers/types';
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
import { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake';
import {
storeSecret, getSecret, deleteSecret, listSecrets,
hasKeyring, knownSecretKeys, SECRET_KEY_LABELS,
} from '../../adapters/secrets-bridge';
import {
checkForUpdates,
getCurrentVersion,
getLastCheckTimestamp,
type UpdateInfo,
} from '../../utils/updater';
import {
getPluginEntries,
setPluginEnabled,
reloadAllPlugins,
type PluginEntry,
} from '../../stores/plugins.svelte';
const PROJECT_ICONS = [
'📁', '🚀', '🤖', '🌐', '🔧', '🎮', '📱', '💻',
@ -61,6 +77,23 @@
let filesSaveOnBlur = $state(false);
let selectedTheme = $state<ThemeId>(getCurrentTheme());
// Updater state
let appVersion = $state('');
let updateCheckResult = $state<UpdateInfo | null>(null);
let updateChecking = $state(false);
let updateLastCheck = $state<string>('');
// Secrets state
let keyringAvailable = $state(false);
let storedKeys = $state<string[]>([]);
let knownKeys = $state<string[]>([]);
let revealedKey = $state<string | null>(null);
let revealedValue = $state('');
let newSecretKey = $state('');
let newSecretValue = $state('');
let secretsKeyDropdownOpen = $state(false);
let secretsSaving = $state(false);
// Dropdown open states
let themeDropdownOpen = $state(false);
let uiFontDropdownOpen = $state(false);
@ -152,12 +185,40 @@
} catch {
providerSettings = {};
}
// Load secrets state
try {
keyringAvailable = await hasKeyring();
if (keyringAvailable) {
storedKeys = await listSecrets();
knownKeys = await knownSecretKeys();
}
} catch {
keyringAvailable = false;
}
// Load app version for updater section
appVersion = await getCurrentVersion();
const ts = getLastCheckTimestamp();
if (ts) updateLastCheck = new Date(ts).toLocaleString();
});
function applyCssProp(prop: string, value: string) {
document.documentElement.style.setProperty(prop, value);
}
async function handleCheckForUpdates() {
updateChecking = true;
try {
updateCheckResult = await checkForUpdates();
updateLastCheck = new Date().toLocaleString();
} catch {
updateCheckResult = { available: false };
} finally {
updateChecking = false;
}
}
async function saveGlobalSetting(key: string, value: string) {
try {
await setSetting(key, value);
@ -244,6 +305,66 @@
return providerSettings[providerId]?.enabled ?? true;
}
// --- Secrets handlers ---
async function handleRevealSecret(key: string) {
if (revealedKey === key) {
revealedKey = null;
revealedValue = '';
return;
}
try {
const val = await getSecret(key);
revealedKey = key;
revealedValue = val ?? '';
} catch (e) {
console.error(`Failed to reveal secret '${key}':`, e);
}
}
async function handleSaveSecret() {
if (!newSecretKey || !newSecretValue) return;
secretsSaving = true;
try {
await storeSecret(newSecretKey, newSecretValue);
storedKeys = await listSecrets();
newSecretKey = '';
newSecretValue = '';
// If we just saved the currently revealed key, clear reveal
revealedKey = null;
revealedValue = '';
} catch (e) {
console.error('Failed to store secret:', e);
} finally {
secretsSaving = false;
}
}
async function handleDeleteSecret(key: string) {
try {
await deleteSecret(key);
storedKeys = await listSecrets();
if (revealedKey === key) {
revealedKey = null;
revealedValue = '';
}
} catch (e) {
console.error(`Failed to delete secret '${key}':`, e);
}
}
function getSecretKeyLabel(key: string): string {
return SECRET_KEY_LABELS[key] ?? key;
}
let availableKeysForAdd = $derived(
knownKeys.filter(k => !storedKeys.includes(k)),
);
let newSecretKeyLabel = $derived(
newSecretKey ? getSecretKeyLabel(newSecretKey) : 'Select key...',
);
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.custom-dropdown')) {
@ -251,6 +372,7 @@
uiFontDropdownOpen = false;
termFontDropdownOpen = false;
providerDropdownOpenFor = null;
secretsKeyDropdownOpen = false;
}
if (!target.closest('.icon-field')) {
iconPickerOpenFor = null;
@ -267,6 +389,7 @@
termFontDropdownOpen = false;
iconPickerOpenFor = null;
profileDropdownOpenFor = null;
secretsKeyDropdownOpen = false;
}
}
@ -301,6 +424,13 @@
newCwd = '';
}
// Plugin entries (reactive from store)
let pluginEntries = $derived(getPluginEntries());
async function handleReloadPlugins() {
await reloadAllPlugins(activeGroupId);
}
// New group form
let newGroupName = $state('');
@ -548,6 +678,37 @@
</div>
</section>
<section class="settings-section">
<h2>Updates</h2>
<div class="settings-list">
<div class="setting-field">
<span class="setting-label">Current version</span>
<span class="setting-value">{appVersion || '...'}</span>
</div>
{#if updateLastCheck}
<div class="setting-field">
<span class="setting-label">Last checked</span>
<span class="setting-value setting-muted">{updateLastCheck}</span>
</div>
{/if}
{#if updateCheckResult?.available}
<div class="setting-field">
<span class="setting-label">Available</span>
<span class="setting-value update-available">v{updateCheckResult.version}</span>
</div>
{/if}
<div class="setting-field">
<button
class="btn-primary"
onclick={handleCheckForUpdates}
disabled={updateChecking}
>
{updateChecking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
</div>
</section>
<section class="settings-section">
<h2>Providers</h2>
<div class="provider-list">
@ -605,6 +766,171 @@
</div>
</section>
<section class="settings-section">
<h2>Secrets</h2>
<div class="secrets-status">
<span class="keyring-indicator" class:available={keyringAvailable} class:unavailable={!keyringAvailable}></span>
<span class="keyring-label">
{keyringAvailable ? 'System keyring available' : 'System keyring unavailable'}
</span>
</div>
{#if !keyringAvailable}
<div class="secrets-warning">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>System keyring not available. Secrets cannot be stored securely.</span>
</div>
{:else}
{#if storedKeys.length > 0}
<div class="secrets-list">
{#each storedKeys as key}
<div class="secret-row">
<div class="secret-info">
<span class="secret-key-name">{getSecretKeyLabel(key)}</span>
<span class="secret-key-id">{key}</span>
</div>
<div class="secret-value-area">
{#if revealedKey === key}
<input
type="text"
class="secret-value-input"
value={revealedValue}
readonly
/>
{:else}
<span class="secret-masked">{'\u25CF'.repeat(8)}</span>
{/if}
</div>
<div class="secret-actions">
<button
class="secret-btn"
title={revealedKey === key ? 'Hide' : 'Reveal'}
onclick={() => handleRevealSecret(key)}
>
{#if revealedKey === key}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
<button
class="secret-btn secret-btn-danger"
title="Delete"
onclick={() => handleDeleteSecret(key)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
</div>
</div>
{/each}
</div>
{/if}
<div class="secret-add-form">
<div class="secret-add-row">
<div class="custom-dropdown secret-key-dropdown">
<button
class="dropdown-trigger"
onclick={() => { secretsKeyDropdownOpen = !secretsKeyDropdownOpen; }}
aria-haspopup="listbox"
aria-expanded={secretsKeyDropdownOpen}
>
<span class="dropdown-label">{newSecretKeyLabel}</span>
<span class="dropdown-arrow">{secretsKeyDropdownOpen ? '\u25B4' : '\u25BE'}</span>
</button>
{#if secretsKeyDropdownOpen}
<div class="dropdown-menu" role="listbox">
{#each availableKeysForAdd as key}
<button
class="dropdown-option"
class:active={newSecretKey === key}
role="option"
aria-selected={newSecretKey === key}
onclick={() => { newSecretKey = key; secretsKeyDropdownOpen = false; }}
>
<span class="dropdown-option-label">{getSecretKeyLabel(key)}</span>
<span class="secret-key-hint">{key}</span>
</button>
{/each}
{#if availableKeysForAdd.length === 0}
<span class="dropdown-empty">All keys configured</span>
{/if}
</div>
{/if}
</div>
<input
type="password"
class="secret-value-new"
bind:value={newSecretValue}
placeholder="Secret value"
disabled={!newSecretKey}
/>
<button
class="btn-primary"
onclick={handleSaveSecret}
disabled={!newSecretKey || !newSecretValue || secretsSaving}
>
{secretsSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/if}
</section>
<section class="settings-section">
<h2>Plugins</h2>
{#if pluginEntries.length === 0}
<p class="empty-notice">No plugins found in ~/.config/bterminal/plugins/</p>
{:else}
<div class="plugin-list">
{#each pluginEntries as entry (entry.meta.id)}
<div class="plugin-row">
<div class="plugin-info">
<span class="plugin-name">{entry.meta.name}</span>
<span class="plugin-version">v{entry.meta.version}</span>
{#if entry.status === 'loaded'}
<span class="plugin-badge loaded" title="Loaded">loaded</span>
{:else if entry.status === 'error'}
<span class="plugin-badge error" title={entry.error ?? 'Error'}>error</span>
{:else if entry.status === 'disabled'}
<span class="plugin-badge disabled">disabled</span>
{:else}
<span class="plugin-badge discovered">discovered</span>
{/if}
</div>
{#if entry.meta.description}
<p class="plugin-desc">{entry.meta.description}</p>
{/if}
{#if entry.meta.permissions.length > 0}
<div class="plugin-perms">
{#each entry.meta.permissions as perm}
<span class="perm-badge">{perm}</span>
{/each}
</div>
{/if}
{#if entry.error}
<p class="plugin-error">{entry.error}</p>
{/if}
<label class="card-toggle" title={entry.status === 'disabled' ? 'Disabled' : 'Enabled'}>
<input
type="checkbox"
checked={entry.status !== 'disabled'}
onchange={async (e) => {
const enabled = (e.target as HTMLInputElement).checked;
await setPluginEnabled(entry.meta.id, enabled);
}}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
{/each}
</div>
{/if}
<button class="btn-primary reload-plugins-btn" onclick={handleReloadPlugins}>
Reload Plugins
</button>
</section>
<section class="settings-section">
<h2>Groups</h2>
<div class="group-list">
@ -962,6 +1288,21 @@
</label>
</div>
<div class="card-field card-field-row">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Sandbox (Landlock)
</span>
<label class="card-toggle" title={project.sandboxEnabled ? 'Filesystem sandbox enabled' : 'Filesystem sandbox disabled'}>
<input
type="checkbox"
checked={project.sandboxEnabled ?? false}
onchange={e => updateProject(activeGroupId, project.id, { sandboxEnabled: (e.target as HTMLInputElement).checked })}
/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
@ -1068,6 +1409,21 @@
letter-spacing: 0.03em;
}
.setting-value {
font-size: 0.8rem;
color: var(--ctp-text);
}
.setting-muted {
color: var(--ctp-overlay0);
font-size: 0.75rem;
}
.update-available {
color: var(--ctp-green);
font-weight: 600;
}
.setting-field > input,
.setting-field .input-with-browse input {
padding: 0.375rem 0.625rem;
@ -2044,4 +2400,313 @@
overflow-y: auto;
border-top: 1px solid var(--ctp-surface1);
}
/* Secrets section */
.secrets-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.625rem;
}
.keyring-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.keyring-indicator.available {
background: var(--ctp-green);
box-shadow: 0 0 4px color-mix(in srgb, var(--ctp-green) 50%, transparent);
}
.keyring-indicator.unavailable {
background: var(--ctp-red);
box-shadow: 0 0 4px color-mix(in srgb, var(--ctp-red) 50%, transparent);
}
.keyring-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
}
.secrets-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, var(--ctp-red) 8%, var(--ctp-surface0));
border: 1px solid color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface1));
border-radius: 0.375rem;
color: var(--ctp-red);
font-size: 0.75rem;
line-height: 1.4;
}
.secrets-warning svg {
flex-shrink: 0;
}
.secrets-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.625rem;
}
.secret-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
transition: border-color 0.15s;
}
.secret-row:hover {
border-color: var(--ctp-surface2);
}
.secret-info {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
flex-shrink: 0;
}
.secret-key-name {
font-size: 0.78rem;
font-weight: 600;
color: var(--ctp-text);
white-space: nowrap;
}
.secret-key-id {
font-size: 0.625rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
}
.secret-value-area {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.secret-masked {
color: var(--ctp-overlay0);
font-size: 0.75rem;
letter-spacing: 0.1em;
}
.secret-value-input {
width: 100%;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.75rem;
font-family: var(--term-font-family, monospace);
}
.secret-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.secret-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
background: transparent;
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-overlay1);
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.secret-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
border-color: var(--ctp-surface2);
}
.secret-btn-danger:hover {
color: var(--ctp-red);
background: color-mix(in srgb, var(--ctp-red) 8%, transparent);
border-color: color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface1));
}
.secret-add-form {
padding: 0.5rem 0.625rem;
background: var(--ctp-mantle);
border: 1px dashed var(--ctp-surface1);
border-radius: 0.375rem;
}
.secret-add-row {
display: flex;
gap: 0.375rem;
align-items: stretch;
}
.secret-key-dropdown {
min-width: 10rem;
flex-shrink: 0;
}
.secret-key-hint {
font-size: 0.625rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
margin-left: auto;
padding-left: 0.5rem;
}
.dropdown-empty {
display: block;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
color: var(--ctp-overlay0);
font-style: italic;
}
.secret-value-new {
flex: 1;
min-width: 0;
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8rem;
}
.secret-value-new:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.secret-value-new:focus {
border-color: var(--ctp-blue);
outline: none;
}
/* --- Plugins section --- */
.plugin-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.625rem;
}
.plugin-row {
position: relative;
background: var(--ctp-surface0);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
}
.plugin-info {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.125rem;
}
.plugin-name {
font-size: 0.82rem;
font-weight: 600;
color: var(--ctp-text);
}
.plugin-version {
font-size: 0.68rem;
color: var(--ctp-overlay0);
}
.plugin-badge {
font-size: 0.6rem;
padding: 0.05rem 0.3rem;
border-radius: 0.1875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.plugin-badge.loaded {
background: color-mix(in srgb, var(--ctp-green) 20%, transparent);
color: var(--ctp-green);
}
.plugin-badge.error {
background: color-mix(in srgb, var(--ctp-red) 20%, transparent);
color: var(--ctp-red);
}
.plugin-badge.disabled {
background: color-mix(in srgb, var(--ctp-overlay0) 20%, transparent);
color: var(--ctp-overlay0);
}
.plugin-badge.discovered {
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
color: var(--ctp-blue);
}
.plugin-desc {
font-size: 0.75rem;
color: var(--ctp-subtext0);
margin: 0.125rem 0 0.25rem;
}
.plugin-perms {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
margin-top: 0.125rem;
}
.perm-badge {
font-size: 0.6rem;
padding: 0.05rem 0.25rem;
border-radius: 0.125rem;
background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent);
color: var(--ctp-mauve);
font-family: var(--font-mono, monospace);
}
.plugin-error {
font-size: 0.7rem;
color: var(--ctp-red);
margin: 0.25rem 0 0;
word-break: break-word;
}
.plugin-row .card-toggle {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.empty-notice {
font-size: 0.78rem;
color: var(--ctp-overlay0);
margin: 0 0 0.5rem;
}
.reload-plugins-btn {
margin-top: 0.25rem;
}
</style>

View file

@ -1,32 +1,75 @@
// Auto-update checker — uses Tauri updater plugin
// Requires signing key to be configured in tauri.conf.json before use
import { check } from '@tauri-apps/plugin-updater';
import { check, type Update } from '@tauri-apps/plugin-updater';
import { getVersion } from '@tauri-apps/api/app';
export async function checkForUpdates(): Promise<{
export interface UpdateInfo {
available: boolean;
version?: string;
notes?: string;
}> {
date?: string;
currentVersion?: string;
}
// Cache the last check result for UI access
let lastCheckResult: UpdateInfo | null = null;
let lastCheckTimestamp: number | null = null;
let cachedUpdate: Update | null = null;
export function getLastCheckResult(): UpdateInfo | null {
return lastCheckResult;
}
export function getLastCheckTimestamp(): number | null {
return lastCheckTimestamp;
}
export async function getCurrentVersion(): Promise<string> {
try {
const update = await check();
return await getVersion();
} catch {
return '0.0.0';
}
}
export async function checkForUpdates(): Promise<UpdateInfo> {
try {
const [update, currentVersion] = await Promise.all([check(), getCurrentVersion()]);
lastCheckTimestamp = Date.now();
if (update) {
return {
cachedUpdate = update;
lastCheckResult = {
available: true,
version: update.version,
notes: update.body ?? undefined,
date: update.date ?? undefined,
currentVersion,
};
} else {
cachedUpdate = null;
lastCheckResult = {
available: false,
currentVersion,
};
}
return { available: false };
return lastCheckResult;
} catch {
// Updater not configured or network error — silently skip
return { available: false };
lastCheckResult = { available: false };
lastCheckTimestamp = Date.now();
return lastCheckResult;
}
}
export async function installUpdate(): Promise<void> {
const update = await check();
// Use cached update from last check if available
const update = cachedUpdate ?? (await check());
if (update) {
// downloadAndInstall will restart the app after installation
await update.downloadAndInstall();
// If we reach here, the app should relaunch automatically
}
}