Updates card: scan npm/cargo deps, surface security advisories, install compatible updates

This commit is contained in:
Avi 2026-08-24 09:54:04 -05:00
commit 8bce42810a
11 changed files with 762 additions and 5 deletions

View file

@ -139,10 +139,11 @@ function startBackend(): void {
/**
* Upper bound for one backend round-trip. Generous on purpose: a publish can
* wait for each relay in turn (10s connect + 15s send each). A hung backend
* still gets reaped instead of leaking promises forever.
* wait on relays and dependency updates run npm/cargo commands that can take
* minutes on cold caches. A hung backend still gets reaped instead of leaking
* promises forever.
*/
const BACKEND_TIMEOUT_MS = 120_000;
const BACKEND_TIMEOUT_MS = 300_000;
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
startBackend();
@ -196,6 +197,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'relay_set_enabled',
'relay_test',
'settings_update',
'update_check',
'update_apply',
'backup_now',
'set_vault_password',
'unlock_vault',

View file

@ -11,6 +11,8 @@ import type {
RevealedKey,
Settings,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
UploadedImage,
} from './types';
@ -80,6 +82,8 @@ export const api = {
relaySetEnabled: (url: string, enabled: boolean) =>
call<Settings>('relay_set_enabled', { url, enabled }),
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
updateCheck: () => call<UpdateCheckReport>('update_check'),
updateApply: () => call<UpdateApplyReport>('update_apply'),
settingsUpdate: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch),

View file

@ -94,6 +94,36 @@ export interface RelayTestResult {
latency_ms?: number | null;
}
/** One dependency with a newer compatible version available. */
export interface PackageUpdate {
name: string;
current: string;
available: string;
}
/** A known security advisory affecting an npm dependency. */
export interface SecurityAdvisory {
package: string;
/** npm severity label: critical / high / moderate / low / info. */
severity: string;
title: string | null;
}
/** Result of scanning both dependency sets for updates. */
export interface UpdateCheckReport {
outdated_npm: PackageUpdate[];
advisories: SecurityAdvisory[];
outdated_cargo: PackageUpdate[];
notes: string[];
}
/** Result of applying dependency updates. */
export interface UpdateApplyReport {
applied: string[];
failed: string[];
restart_required: boolean;
}
export interface AppState {
version: string;
vault_path: string;

View file

@ -1,17 +1,24 @@
import { useState } from 'react';
import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon';
import { Spinner } from '../components/Spinner';
import { Toggle } from '../components/Toggle';
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal';
import type { Theme } from '../lib/types';
import type { Theme, UpdateCheckReport } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SettingsScreen() {
const { state, updateSettings, backupNow } = useApp();
const { state, updateSettings, backupNow, updateCheck, updateApply } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
const [updateReport, setUpdateReport] = useState<UpdateCheckReport | null>(null);
const [checking, setChecking] = useState(false);
const [installing, setInstalling] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [applyMessage, setApplyMessage] = useState<string[] | null>(null);
const settings = state?.settings;
const vaultPath = state?.vault_path ?? '';
@ -44,6 +51,38 @@ export function SettingsScreen() {
}
};
const onCheckUpdates = async () => {
setChecking(true);
setUpdateError(null);
setApplyMessage(null);
setUpdateReport(null);
try {
setUpdateReport(await updateCheck());
} catch (err) {
setUpdateError(err instanceof Error ? err.message : String(err));
} finally {
setChecking(false);
}
};
const onInstallUpdates = async () => {
setInstalling(true);
setUpdateError(null);
setApplyMessage(null);
try {
const result = await updateApply();
const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)];
if (result.restart_required) {
lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.');
}
setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']);
} catch (err) {
setUpdateError(err instanceof Error ? err.message : String(err));
} finally {
setInstalling(false);
}
};
return (
<div className="screen">
<div className="screen-inner">
@ -153,6 +192,127 @@ export function SettingsScreen() {
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Updates</h2>
</header>
<div className="card-body settings-block">
<p className="hint">
Scans the JavaScript packages and Rust crates this app is built on. Known security
advisories are always listed first; installing applies compatible updates only.
</p>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onCheckUpdates()} loading={checking}>
<Icon name="refresh" size={16} />
Check for updates
</Button>
<Button
variant="primary"
onClick={() => void onInstallUpdates()}
loading={installing}
disabled={checking}
>
<Icon name="shield" size={16} />
Install updates
</Button>
</div>
{updateError && (
<Alert tone="error" title="Update problem">
{updateError}
</Alert>
)}
{checking && <Spinner label="Scanning dependencies…" />}
{applyMessage && (
<Alert tone="info" title="Updates">
<ul className="update-summary">
{applyMessage.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Alert>
)}
{updateReport && !checking && (
<>
{updateReport.advisories.length > 0 ? (
<Alert
tone="warning"
title={`${updateReport.advisories.length} security issue(s) found`}
>
<ul className="update-list">
{updateReport.advisories.map((advisory) => (
<li key={advisory.package}>
<Badge
tone={
advisory.severity === 'critical' || advisory.severity === 'high'
? 'danger'
: advisory.severity === 'moderate'
? 'warning'
: 'neutral'
}
>
{advisory.severity}
</Badge>{' '}
<code className="mono">{advisory.package}</code>
{advisory.title ? ` — ${advisory.title}` : ''}
</li>
))}
</ul>
</Alert>
) : (
<Alert
tone="success"
title="No known security advisories in the npm dependencies."
/>
)}
{updateReport.outdated_npm.length > 0 && (
<div>
<span className="field-label">JavaScript packages</span>
<ul className="update-list">
{updateReport.outdated_npm.map((pkg) => (
<li key={pkg.name}>
<code className="mono">{pkg.name}</code> {pkg.current} →{' '}
<strong>{pkg.available}</strong>
</li>
))}
</ul>
</div>
)}
{updateReport.outdated_cargo.length > 0 && (
<div>
<span className="field-label">Rust crates</span>
<ul className="update-list">
{updateReport.outdated_cargo.map((crateName) => (
<li key={crateName.name}>
<code className="mono">{crateName.name}</code> {crateName.current} →{' '}
<strong>{crateName.available}</strong>
</li>
))}
</ul>
</div>
)}
{updateReport.notes.map((note) => (
<p className="hint" key={note}>
{note}
</p>
))}
{updateReport.advisories.length === 0 &&
updateReport.outdated_npm.length === 0 &&
updateReport.outdated_cargo.length === 0 && (
<Alert tone="success">Everything is up to date.</Alert>
)}
</>
)}
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Advanced</h2>

View file

@ -21,6 +21,8 @@ import type {
Settings,
SignerStatus,
Theme,
UpdateApplyReport,
UpdateCheckReport,
UploadedImage,
} from '../lib/types';
@ -51,6 +53,8 @@ interface AppContextValue {
relayRemove: (url: string) => Promise<Settings>;
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
relayTest: (url: string) => Promise<RelayTestResult>;
updateCheck: () => Promise<UpdateCheckReport>;
updateApply: () => Promise<UpdateApplyReport>;
updateSettings: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>;
@ -190,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
[applySettings],
);
const relayTest = useCallback((url: string) => api.relayTest(url), []);
const updateCheck = useCallback(() => api.updateCheck(), []);
const updateApply = useCallback(() => api.updateApply(), []);
const updateSettings = useCallback(
async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
applySettings(await api.settingsUpdate(patch)),
@ -252,6 +258,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayRemove,
relaySetEnabled,
relayTest,
updateCheck,
updateApply,
updateSettings,
backupNow,
setVaultPassword,
@ -296,6 +304,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayRemove,
relaySetEnabled,
relayTest,
updateCheck,
updateApply,
updateSettings,
backupNow,
setVaultPassword,

View file

@ -256,6 +256,21 @@ a {
height: 34px;
}
.update-list {
margin: 6px 0 0;
padding-left: 18px;
display: grid;
gap: 4px;
font-size: 14px;
}
.update-summary {
margin: 6px 0 0;
padding-left: 18px;
display: grid;
gap: 4px;
}
/* -------------------------------------------------------------------------
Sidebar
------------------------------------------------------------------------- */

View file

@ -64,4 +64,64 @@ describe('SettingsScreen', () => {
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument();
expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument();
});
it('checks for updates and lists security advisories first', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Check for updates/i }));
expect(await screen.findByText('1 security issue(s) found')).toBeInTheDocument();
expect(screen.getByText(/minimist/)).toBeInTheDocument();
expect(screen.getByText('JavaScript packages')).toBeInTheDocument();
expect(screen.getByText('Rust crates')).toBeInTheDocument();
const checkRequest = backend.requests.find((r) => r.method === 'update_check');
expect(checkRequest).toBeDefined();
});
it('reports an up-to-date app when the scan finds nothing', async () => {
const backend = createFakeBackend();
backend.updateReport = {
outdated_npm: [],
advisories: [],
outdated_cargo: [],
notes: [],
};
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Check for updates/i }));
expect(await screen.findByText('Everything is up to date.')).toBeInTheDocument();
});
it('installs updates and asks for a rebuild + restart', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Install updates/i }));
expect(await screen.findByText(/Rebuild and restart the app/)).toBeInTheDocument();
expect(screen.getByText('JavaScript security fixes applied')).toBeInTheDocument();
const applyRequest = backend.requests.find((r) => r.method === 'update_apply');
expect(applyRequest).toBeDefined();
});
it('surfaces update failures as errors', async () => {
const backend = createFakeBackend();
backend.nextErrors.update_check = { message: 'npm was not found on this computer.' };
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Check for updates/i }));
expect(await screen.findByText('Update problem')).toBeInTheDocument();
expect(screen.getByText('npm was not found on this computer.')).toBeInTheDocument();
});
});

View file

@ -7,6 +7,8 @@ import type {
RelayTestResult,
Settings,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
} from '../lib/types';
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
@ -45,6 +47,10 @@ export interface FakeBackend {
contactFeedItems: FeedItem[];
/** Notes returned by `feed_get` with an `author` filter. */
profileFeedItems: FeedItem[];
/** Report returned by `update_check`. */
updateReport: UpdateCheckReport;
/** Result returned by `update_apply`. */
updateApplyResult: UpdateApplyReport;
}
export function createFakeBackend(initial?: AppState): FakeBackend {
@ -139,6 +145,23 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
relays: ['wss://relay.damus.io'],
},
],
updateReport: {
outdated_npm: [{ name: 'vite', current: '4.0.0', available: '5.1.0' }],
advisories: [
{
package: 'minimist',
severity: 'high',
title: 'Prototype Pollution',
},
],
outdated_cargo: [{ name: 'serde', current: '1.0.200', available: '1.0.219' }],
notes: [],
},
updateApplyResult: {
applied: ['JavaScript security fixes applied', 'Rust crates updated in Cargo.lock'],
failed: [],
restart_required: true,
},
};
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
@ -361,6 +384,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return result;
}
case 'update_check':
return backend.updateReport;
case 'update_apply':
return backend.updateApplyResult;
case 'settings_update': {
const nextSettings: Settings = { ...state.settings, ...params };
backend.setState({ ...state, settings: nextSettings });