Updates card: scan npm/cargo deps, surface security advisories, install compatible updates
This commit is contained in:
parent
3dfd15f9c1
commit
8bce42810a
11 changed files with 762 additions and 5 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue