Compare commits

...
Author SHA1 Message Date
bincxzandCursor cb21936978 feat(plugins): polish manager metadata and support verified README images
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 16:15:15 +08:00
37 changed files with 387 additions and 55 deletions

No files matched your search

+3
View File
@@ -1375,6 +1375,9 @@ Highlight the focused split pane:
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Official",
"settings.plugins.sources.short.thirdparty": "Third-party",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
+3
View File
@@ -1375,6 +1375,9 @@ Resalta el panel dividido enfocado:
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Oficial",
"settings.plugins.sources.short.thirdparty": "Terceros",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
+3
View File
@@ -1240,6 +1240,9 @@ export const ruCoreMessages: Messages = {
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Официальный",
"settings.plugins.sources.short.thirdparty": "Сторонний",
"settings.plugins.sources.short.local": "Локальный",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
+3
View File
@@ -1290,6 +1290,9 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本地",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
+3
View File
@@ -1261,6 +1261,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本機",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
+11
View File
@@ -10,6 +10,7 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [imageState, setImageState] = useState<{ id: string; token?: string; sourceId: string; images: Record<string, string> } | null>(null);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
@@ -51,9 +52,19 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
useEffect(() => {
let active = true;
if (selectedId && selectionToken) {
void netcattyBridge.get()?.getPluginCatalogReadmeImages?.(selectedId, selectionToken, sourceId)
.then((images) => { if (active) setImageState({ id: selectedId, token: selectionToken, sourceId, images }); })
.catch(() => { if (active) setImageState(null); });
}
return () => { active = false; };
}, [selectedId, selectionToken, sourceId, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source, sourceBinding,
readmeImages: imageState?.id === selectedId && imageState.token === selectionToken && imageState.sourceId === sourceId ? imageState.images : {},
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
+20 -11
View File
@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import { UserRound } from 'lucide-react';
import { Globe, HardDrive, Store } from 'lucide-react';
import { usePluginAuthorIdentity } from '../../application/state/usePluginAuthorIdentity';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
export function PluginAuthorRow({ entry, plugin, sourceId }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string; showSource?: boolean;
}) {
const { t } = useI18n();
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
@@ -14,13 +14,22 @@ export function PluginAuthorRow({ entry, plugin, sourceId }: {
const imageKey = JSON.stringify([entry?.id, entry?.selectionToken, plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, sourceId, identity?.avatar]);
const avatar = identity?.avatar;
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
return <div ref={ref} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground" data-plugin-author-row>
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted/60" aria-hidden="true">
{showImage ? <img src={avatar} alt="" width={20} height={20} className="h-full w-full object-cover" onError={() => setFailed(imageKey)} />
: <UserRound size={13} data-plugin-author-placeholder />}
</span>
<span className="min-w-0 truncate" title={author}>{author}</span>
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
const source = plugin?.source;
const sourceKey = source?.kind === 'third-party' ? 'thirdparty' : source?.kind === 'official' || source?.kind === 'local' ? source.kind : undefined;
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
const sourceDescription = sourceKey
? [t(`settings.plugins.sources.${sourceKey}`), source?.name, source?.origin].filter(Boolean).join(' · ')
: sourceLabel;
const SourceIcon = source?.kind === 'local' ? HardDrive : source?.kind === 'official' ? Store : Globe;
return <div ref={ref} className="flex min-w-0 items-center gap-3 overflow-hidden text-xs text-muted-foreground" data-plugin-author-row>
{(author || showImage || identity?.officialMaintained === true) && <span data-plugin-author className="inline-flex min-w-0 items-center gap-1.5 overflow-hidden">
{showImage && <img src={avatar} alt="" width={16} height={16} className="h-4 w-4 shrink-0 rounded-full object-cover" onError={() => setFailed(imageKey)} />}
{author && <span className="min-w-0 truncate" title={author}>{author}</span>}
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
</span>}
{showSource && <span data-plugin-source className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap" title={sourceDescription} aria-label={sourceDescription}>
<SourceIcon size={12} aria-hidden="true" />{sourceLabel}
</span>}
</div>;
}
@@ -12,14 +12,53 @@ const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty',
const identity = { author: 'Netcatty', avatar: 'data:image/png;base64,eA==', officialMaintained: true,
sourceId: 'official', version: entry.version, archiveSha256: null };
test('untrusted entry strings and absent imagery produce a compact placeholder without an official badge', () => {
test('untrusted entry strings and absent imagery show only the author without decoration or an official badge', () => {
const claimed = { ...entry, author: '<img src="https://avatars.githubusercontent.com/u/1">',
officialMaintained: true, avatar: 'https://avatars.githubusercontent.com/u/1' };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow entry={claimed} /></I18nProvider>);
assert.match(html, /data-plugin-author-placeholder/);
assert.doesNotMatch(html, /<img|data-plugin-official-maintainer/);
assert.doesNotMatch(html, /<img|<svg|data-plugin-author-placeholder|bg-muted|rounded-full|data-plugin-official-maintainer/);
assert.match(html, /&lt;img/);
assert.match(html, /items-center/);
});
test('installed authors share a compact row with localized sources and full accessible provenance', () => {
const plugin = { id: entry.id, manifest: { publisher: 'An extremely long publisher name' },
source: { id: 'third-party:example', kind: 'third-party', name: 'Community packages', origin: 'https://packages.example.org/catalog' },
} as NetcattyInstalledPlugin;
for (const [locale, label] of [['en', 'Third-party'], ['zh-CN', '第三方']]) {
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale={locale}><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
const source = row.querySelector<HTMLElement>('[data-plugin-source]')!;
assert.ok(source, 'source belongs to the author row');
assert.equal(source.textContent, label);
assert.match(source.title, /Community packages.*packages\.example\.org/);
assert.equal(source.getAttribute('aria-label'), source.title);
assert.ok(source.classList.contains('shrink-0'));
assert.ok(row.classList.contains('min-w-0'));
assert.ok(row.classList.contains('gap-3'), 'author and source are separated by 12px');
assert.ok(row.classList.contains('overflow-hidden'), 'metadata is bounded even at extremely narrow widths');
const authorGroup = row.querySelector('[data-plugin-author]')!;
assert.equal(authorGroup.parentElement, row);
assert.equal(source.parentElement, row);
assert.ok(authorGroup.classList.contains('min-w-0'));
assert.ok(authorGroup.classList.contains('gap-1.5'), 'avatar and author have a 6px gap');
assert.ok(source.classList.contains('gap-1'), 'source icon and text have a 4px gap');
assert.ok(row.querySelector('[title="An extremely long publisher name"]')?.classList.contains('truncate'));
assert.ok(source.querySelector('svg[aria-hidden="true"]'));
assert.equal(row.querySelector('[data-plugin-official-maintainer]'), null);
dom.window.close();
}
});
test('missing authors and unknown sources do not leave an empty author group or expose translation keys', () => {
for (const source of [undefined, { kind: 'unexpected' }]) {
const plugin = { id: entry.id, manifest: {}, source } as NetcattyInstalledPlugin;
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
assert.equal(row.querySelector('[data-plugin-author]'), null);
assert.equal(row.textContent, 'Source unknown');
assert.equal(row.querySelectorAll('svg').length, 1, 'only the source outline icon remains');
dom.window.close();
}
});
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
@@ -58,12 +97,13 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
});
};
try {
await render(); assert.equal(calls.length, 0); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
await render(); assert.equal(calls.length, 0); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
assert.ok(document.querySelector('[data-plugin-author-placeholder]'), 'loading uses the default placeholder');
assert.equal(document.querySelector('img'), null, 'loading does not reserve an empty avatar');
assert.equal(document.querySelector('[data-plugin-author]')?.textContent, 'Netcatty');
await act(async () => pending.shift()!(identity));
const image = document.querySelector('img')!;
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 20); assert.equal(image.height, 20);
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 16); assert.equal(image.height, 16);
const badge = document.querySelector<HTMLElement>('[data-plugin-official-maintainer]')!;
assert.equal(badge.textContent, 'Official maintainer'); assert.match(badge.title, /not a safety certification/);
assert.ok(badge.classList.contains('border-0'));
@@ -72,7 +112,7 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
document.documentElement.classList.add('dark');
assert.ok(document.querySelector('[data-plugin-author-row]')?.classList.contains('items-center'));
await act(async () => image.dispatchEvent(new Event('error')));
assert.equal(document.querySelector('img'), null); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
assert.equal(document.querySelector('img'), null); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
const next = { ...entry, selectionToken: 'c'.repeat(64) };
@@ -89,7 +129,7 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
await render({ ...entry, selectionToken: 'f'.repeat(64) }); await visible();
await act(async () => pending.shift()!({ ...identity, avatar: null, officialMaintained: false }));
assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
const installed = { id: entry.id, activeVersion: entry.version, archiveSha256: 'a'.repeat(64),
source: { id: 'official', kind: 'official', origin: 'https://plugins.netcatty.app', name: 'Official' },
manifest: entry.manifest, runtime: {} } as NetcattyInstalledPlugin;
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readmeImages: {}, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
@@ -34,7 +34,9 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
assert.equal(authorRow.findAllByType('img').length, 0, 'a publisher without a real avatar uses text only');
assert.equal(authorRow.findAllByProps({ 'data-plugin-author-placeholder': true }).length, 0);
assert.ok(authorRow.props.className.includes('gap-3'), 'author and source groups have 12px separation');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
@@ -38,6 +38,12 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
trigger.focus();
await click(trigger);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
// JSDOM cannot emulate Electron's native drag hit testing. Protect the
// no-drag boundary that keeps the portaled manager above the titlebar clickable.
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
assert.equal(dialog.classList.contains('app-no-drag'), true, 'plugin manager must exclude its content from native window dragging');
const visibleClose = dialog.querySelector<HTMLButtonElement>('button[data-dialog-close]:not([aria-hidden])')!;
assert.equal(visibleClose.closest('.app-no-drag'), dialog, 'the visible X must be inside the no-drag boundary');
assert.equal(mounts, 1);
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search');
assert.equal(document.querySelector('[role="dialog"]')?.contains(document.querySelector('[data-settings-scroll-pane]')), true);
@@ -98,6 +104,8 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
} finally {
await act(async () => root.unmount());
// Let Radix finish deferred focus cleanup before restoring the DOM globals.
await settle();
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
@@ -39,7 +39,7 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
target.focus({ preventScroll: true });
}
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
}} className="app-no-drag flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readmeImages: {}, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
@@ -103,7 +103,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} images={catalog.readmeImages} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
</div>
</article>}
</div>
@@ -3,7 +3,28 @@ import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginPackageDetails, PluginPackageManager } from './PluginPackageManager';
import { JSDOM } from 'jsdom';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed cards and selected details keep author before compact source metadata', () => {
const plugin = { id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
source: { id: 'local', kind: 'local', name: 'Imported package', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.synthetic', name: 'Synthetic', publisher: 'Publisher', version: '1.0.0', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: 'Diagnostic remains visible', quarantinedAt: null },
} as NetcattyInstalledPlugin;
const manager = { plugins: [plugin], available: true, statusKnown: true, busy: false } as ReturnType<typeof usePluginManager>;
for (const initialSelectedId of [null, plugin.id]) {
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageManager manager={manager} initialSelectedId={initialSelectedId} /></I18nProvider>));
const article = dom.window.document.querySelector('article')!;
const row = article.querySelector('[data-plugin-author-row]')!;
assert.equal(row.querySelector('[data-plugin-source]')?.textContent, 'Local');
assert.match(row.textContent!, /PublisherLocal/);
assert.doesNotMatch(article.textContent!, /Source:/);
assert.match(article.textContent!, /Diagnostic remains visible/);
dom.window.close();
}
});
test('package detail renders manifest resources as text and explains advanced authority', () => {
const plugin = {
@@ -103,8 +103,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<PluginAuthorRow plugin={plugin} showSource />
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
+10 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PluginReadme } from './PluginReadme';
@@ -12,3 +12,12 @@ test('README never creates active HTML, remote media or script links', () => {
assert.match(html, /rel="noopener noreferrer"/);
assert.match(html, /&lt;script&gt;/);
});
test('README only renders registered local rasterized images and placeholders otherwise', () => {
const path = `/assets/${'a'.repeat(64)}.png`;
const html = renderToStaticMarkup(<PluginReadme markdown={`![ok](${path}) ![tracker](https://evil.test/x) ![bad](/unknown)`} images={{ [path]: 'data:image/png;base64,YQ==' }} />);
assert.match(html, /src="data:image\/png;base64,YQ=="/);
assert.match(html, /\[tracker\]/);
assert.match(html, /\[bad\]/);
assert.doesNotMatch(html, /evil\.test/);
});
+11 -6
View File
@@ -2,9 +2,9 @@ import React, { useMemo } from 'react';
import { fromMarkdown } from 'mdast-util-from-markdown';
import type { RootContent, Root } from 'mdast';
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
function renderNode(node: Root | RootContent, key: number, images: Record<string, string>, depth = 0): React.ReactNode {
if (depth > 24) return null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, depth + 1)) : null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, images, depth + 1)) : null;
switch (node.type) {
case 'root': return <React.Fragment key={key}>{children}</React.Fragment>;
case 'heading': return <h4 key={key} className="mt-5 font-semibold">{children}</h4>;
@@ -24,14 +24,19 @@ function renderNode(node: Root | RootContent, key: number, depth = 0): React.Rea
try { const url = new URL(node.url); safe = url.protocol === 'https:' && !url.username && !url.password; } catch { /* relative and unsafe links stay text */ }
return safe ? <a key={key} href={node.url} target="_blank" rel="noopener noreferrer" className="underline">{children}</a> : <span key={key}>{children}</span>;
}
// Never embed HTML, media, SVG, iframes, remote images, or reference URLs.
case 'image': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'image': {
const src = Object.hasOwn(images, node.url) ? images[node.url] : undefined;
return src && /^data:image\/png;base64,[A-Za-z0-9+/]+=*$/.test(src)
? <img key={key} src={src} alt={node.alt ?? ''} className="my-2 max-w-full" />
: <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
}
case 'imageReference': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'html': return <span key={key}>{node.value}</span>;
default: return <React.Fragment key={key}>{children}</React.Fragment>;
}
}
export function PluginReadme({ markdown }: { markdown: string }) {
export function PluginReadme({ markdown, images = {} }: { markdown: string; images?: Record<string, string> }) {
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
return <div className="break-words text-sm">{renderNode(tree, 0, images)}</div>;
}
@@ -42,9 +42,23 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
assert.match(document.body.textContent!, /Synthetic source/);
const installedSection = () => document.querySelectorAll('section')[1];
assert.doesNotMatch(installedSection().textContent!, /Running|Disabled|Source:/);
const sourceRows = [...document.querySelectorAll('[data-plugin-author-row]')];
assert.equal(sourceRows[0].querySelector('[data-plugin-source]')?.textContent, 'Official');
const content = sourceRows[0].parentElement!;
assert.ok(content.classList.contains('gap-1.5'), 'title, metadata and description have 6px vertical gaps');
assert.ok(content.classList.contains('flex-col'));
assert.ok(content.classList.contains('min-w-0'));
assert.equal(content.firstElementChild?.querySelector('[title="Alpha"]')?.textContent, 'Alpha');
assert.ok(content.firstElementChild?.classList.contains('text-sm'), 'title remains 14px');
assert.equal(content.lastElementChild?.textContent, 'Synthetic description');
assert.ok(content.classList.contains('text-xs'), 'metadata and description remain 12px');
assert.ok(!content.parentElement?.classList.contains('mt-0.5'), 'shared SettingRow margin does not compress the local stack');
const thirdParty = sourceRows[1].querySelector<HTMLElement>('[data-plugin-source]')!;
assert.equal(thirdParty.textContent, 'Third-party');
assert.match(thirdParty.title, /Synthetic source.*synthetic\.example\.com/);
assert.equal(thirdParty.getAttribute('aria-label'), thirdParty.title);
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
await act(async () => button('Browse').click());
await act(async () => byLabel('Settings for Alpha').click());
@@ -63,6 +77,15 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
assert.equal(changes.length, 1);
await render(manager, new Set());
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
for (const [status, label] of [['starting', 'Starting'], ['stopping', 'Stopping'], ['error', 'Error'], ['quarantined', 'Quarantined'], ['unexpected', 'Status unknown']]) {
await render({ ...manager, plugins: [{ ...plugins[0], runtime: { ...plugins[0].runtime, status } }] });
assert.ok([...document.querySelectorAll('[role=status]')].some((item) => item.textContent === label), `${status} remains visible`);
}
for (const enabled of [true, false]) {
await render({ ...manager, plugins: [{ ...plugins[0], enabled, runtime: { ...plugins[0].runtime, status: 'stopped' } }] });
assert.equal(installedSection().querySelectorAll('[role=status]').length, 0, 'normal stopped/idle state is conveyed by the toggle');
assert.doesNotMatch(installedSection().textContent!, /Enabled, idle|Disabled|Stopped/);
}
await render({ ...manager, plugins: [] });
assert.match(document.body.textContent!, /No plugins installed/);
await render({ ...manager, plugins: [], statusKnown: false });
@@ -31,15 +31,9 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
const statusOf = (plugin: NetcattyInstalledPlugin) => {
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
const state = plugin.runtime.status;
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
};
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
const source = plugin.source;
if (!source) return t('settings.plugins.landing.sourceUnknown');
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
if (['starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped' && state !== 'running') return t('settings.plugins.landing.status.unknown');
return null;
};
return <>
<SettingsSection title={t('settings.plugins.landing.options')}>
@@ -67,17 +61,19 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const status = statusOf(plugin);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<span className="block break-words">{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} description={<div className="flex min-w-0 flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex min-w-0 items-baseline gap-2 text-sm font-medium text-foreground">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>
<PluginAuthorRow plugin={plugin} showSource />
{status && <span role="status" className="block break-words">{status}</span>}
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
</div>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
@@ -75,6 +75,20 @@
}
}
},
"readmeImages": {
"type": "array",
"maxItems": 8,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "sha256", "size"],
"properties": {
"path": { "type": "string", "pattern": "^/assets/[a-f0-9]{64}\\.(png|webp)$" },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"size": { "type": "integer", "minimum": 1, "maximum": 262144 }
}
}
},
"iconLight": {
"oneOf": [
{
@@ -93,6 +93,12 @@ function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_OR
const key = tuple(plugin);
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
const imagePaths = new Set();
for (const image of plugin.readmeImages ?? []) {
assertAsset(image, ["png", "webp"]);
if (image.size < 1 || imagePaths.has(image.path)) throw new Error("Invalid or duplicate README image");
imagePaths.add(image.path);
}
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
if (plugin.avatar !== null) assertAsset(plugin.avatar, ["png", "webp"]);
const entry = entries.find((item) => tuple(item) === key);
+2 -1
View File
@@ -520,7 +520,8 @@ function createPluginHostService(options) {
interactionService,
hostVaultService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net),
resolveAvatar: (request) => contributionIconService.resolveCatalogAvatar(request) }),
resolveAvatar: (request) => contributionIconService.resolveCatalogAvatar(request),
resolveReadmeImage: require("./readmeImageService.cjs").createReadmeImageResolver({ BrowserWindow: options.electron.BrowserWindow }) }),
companionSupervisor,
contributionIconService,
contributionService,
@@ -49,6 +49,7 @@ class MarketSourcesService {
});
}
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
readmeImages(id, token, sourceId) { return this.#catalog(sourceId).readmeImages(id, token); }
async author(request) {
if (!request || typeof request !== "object" || Array.isArray(request)
|| typeof request.pluginId !== "string" || !/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(request.pluginId)) throw new TypeError("Invalid plugin author request");
+5
View File
@@ -43,6 +43,7 @@ const CHANNELS = Object.freeze({
catalog: "netcatty:plugins:catalog",
catalogChanged: "netcatty:plugins:catalog-changed",
catalogReadme: "netcatty:plugins:catalog-readme",
catalogReadmeImages: "netcatty:plugins:catalog-readme-images",
author: "netcatty:plugins:author",
catalogInstall: "netcatty:plugins:catalog-install",
install: "netcatty:plugins:install",
@@ -689,6 +690,10 @@ function registerPluginBridge(ipcMain, options) {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
});
handle(CHANNELS.catalogReadmeImages, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.readmeImages(payload?.id, payload?.selectionToken, payload?.sourceId);
});
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.install(payload?.id, payload?.selectionToken, payload?.sourceId, payload?.acceptedSourceId);
+28
View File
@@ -0,0 +1,28 @@
"use strict";
const path = require("node:path");
const { createHash } = require("node:crypto");
const { createBoundedIconRasterizer, inspectContributionIconSource } = require("./contributionIconService.cjs");
const { createIsolatedContributionIconRasterizer } = require("./contributionIconRasterizer.cjs");
function createReadmeImageResolver({ BrowserWindow, rasterize } = {}) {
const decode = createBoundedIconRasterizer(rasterize ?? createIsolatedContributionIconRasterizer({ BrowserWindow }), { maxConcurrent: 2, maxQueued: 8 });
let pending = 0;
return async ({ reference, read }) => {
if (pending >= 10) throw new Error("README image queue is full");
pending++;
try {
const body = await read();
if (!Buffer.isBuffer(body) || body.length !== reference.size || body.length > 256 * 1024
|| createHash("sha256").update(body).digest("hex") !== reference.sha256) throw new Error("Invalid README image bytes");
const extension = path.extname(reference.path);
if (![".png", ".webp"].includes(extension)) throw new Error("Unsupported README image");
const inspected = inspectContributionIconSource(body, extension);
const png = await decode({ body, ...inspected, maxEdge: 1024 });
if (!Buffer.isBuffer(png) || !png.length || png.length > 4 * 1024 * 1024) throw new Error("Invalid README image output");
inspectContributionIconSource(png, ".png", 1024);
return `data:image/png;base64,${png.toString("base64")}`;
} finally { pending--; }
};
}
module.exports = { createReadmeImageResolver };
@@ -0,0 +1,26 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHash } = require("node:crypto");
const { createReadmeImageResolver } = require("./readmeImageService.cjs");
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64");
const hash = (body) => createHash("sha256").update(body).digest("hex");
const ref = (body) => ({ path: `/assets/${hash(body)}.png`, sha256: hash(body), size: body.length });
test("verified raster images are decoded in the isolated rasterizer and re-encoded", async () => {
let calls = 0;
const resolve = createReadmeImageResolver({ rasterize: async (input) => { calls++; assert.equal(input.mimeType, "image/png"); assert.equal(input.maxEdge, 1024); return png; } });
assert.match(await resolve({ reference: ref(png), read: async () => png }), /^data:image\/png;base64,/);
assert.equal(calls, 1);
await assert.rejects(resolve({ reference: { ...ref(png), sha256: "a".repeat(64) }, read: async () => png }), /bytes/);
await assert.rejects(resolve({ reference: { ...ref(png), size: 1 }, read: async () => png }), /bytes/);
await assert.rejects(resolve({ reference: { ...ref(png), path: "/a.svg" }, read: async () => png }), /Unsupported/);
const huge = Buffer.from(png); huge.writeUInt32BE(5000, 16);
await assert.rejects(resolve({ reference: ref(huge), read: async () => huge }), /dimensions/);
assert.equal(calls, 1);
});
test("invalid rasterizer output is never returned", async () => {
const resolve = createReadmeImageResolver({ rasterize: async () => Buffer.from("bad") });
await assert.rejects(resolve({ reference: ref(png), read: async () => png }), /header/);
});
@@ -21,6 +21,7 @@ class RegistryCatalogService extends PluginCatalogService {
this.error = null;
this.refreshing = null;
this.resolveAvatar = options.resolveAvatar;
this.resolveReadmeImage = options.resolveReadmeImage;
}
async refresh() {
if (this.refreshing) return this.refreshing;
@@ -82,6 +83,21 @@ class RegistryCatalogService extends PluginCatalogService {
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
return (await fetchVerified(resource, this.fetch, this.origin)).toString("utf8");
}
async readmeImages(id, token) {
this.sourceGuard();
const entry = this.selected(id, token);
const images = this.resources.get(tuple(entry))?.readmeImages ?? [];
const values = await Promise.all(images.map(async (reference) => {
try {
if (!this.resolveReadmeImage) return null;
const value = await this.resolveReadmeImage({ reference, read: () => fetchVerified(reference, this.fetch, this.origin) });
return [reference.path, value];
} catch { return null; }
}));
this.sourceGuard();
this.selected(id, token);
return Object.fromEntries(values.filter(Boolean));
}
async install(id, token) {
this.selected(id, token);
if (!await this.refresh()) throw new Error("Registry refresh failed. Cached catalogs are browse-only; retry when online.");
@@ -55,6 +55,35 @@ test("selection binds source and delivery metadata; changes and withdrawal canno
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("README images are resolved only from the selected snapshot", async () => {
const bytes = Buffer.from("verified image");
const { digest } = require("./fixtures/staticDistributionFixture.cjs");
const ref = { path: `/assets/${digest(bytes)}.png`, sha256: digest(bytes), size: bytes.length };
const files = makeFiles(catalog, (_index, sidecar) => { sidecar.schemaVersion = 2; sidecar.plugins[0].readmeImages = [ref]; });
files.set(ref.path, bytes);
const requests = [];
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); },
resolveReadmeImage: async ({ read }) => { assert.deepEqual(await read(), bytes); return "data:image/png;base64,c2FmZQ=="; } });
const selected = (await service.list()).entries[0];
assert.deepEqual(await service.readmeImages(selected.id, selected.selectionToken), { [ref.path]: "data:image/png;base64,c2FmZQ==" });
await assert.rejects(service.readmeImages(selected.id, "invalid"), /changed/);
assert.equal(requests.filter((url) => url.endsWith(ref.path)).length, 1);
});
test("README image failures use placeholders and stale or disabled sources cannot return images", async () => {
const { digest } = require("./fixtures/staticDistributionFixture.cjs");
const bytes = Buffer.from("x");
const ref = { path: `/assets/${digest(bytes)}.png`, sha256: digest(bytes), size: 1 };
const files = makeFiles(catalog, (_index, sidecar) => { sidecar.schemaVersion = 2; sidecar.plugins[0].readmeImages = [ref]; });
files.set(ref.path, bytes);
let allowed = true;
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url), sourceGuard: () => { if (!allowed) throw new Error("disabled"); }, resolveReadmeImage: async () => { throw new Error("decode failed"); } });
const selected = (await service.list()).entries[0];
assert.deepEqual(await service.readmeImages(selected.id, selected.selectionToken), {});
allowed = false;
await assert.rejects(service.readmeImages(selected.id, selected.selectionToken), /disabled/);
});
test("concurrent list refreshes share one pointer request and complete document set", async () => {
let finish; const requests = []; const files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
@@ -71,6 +71,9 @@ test("cross-document mismatches, withdrawn versions and unsupported assets prese
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
(index, sidecar) => { sidecar.schemaVersion = 2; sidecar.plugins[0].readmeImages = [{ path: "https://evil.example/a.png", sha256: "a".repeat(64), size: 1 }]; },
(index, sidecar) => { sidecar.schemaVersion = 2; sidecar.plugins[0].readmeImages = [{ path: `/assets/${"a".repeat(64)}.png`, sha256: "b".repeat(64), size: 1 }]; },
(index, sidecar) => { sidecar.schemaVersion = 2; sidecar.plugins[0].readmeImages = Array(9).fill({ path: `/assets/${"a".repeat(64)}.png`, sha256: "a".repeat(64), size: 1 }); },
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
(index) => { index.plugins[0].current = "9.0.0"; },
+1
View File
@@ -113,6 +113,7 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("netcatty:plugins:catalog-changed", listener);
},
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
getPluginCatalogReadmeImages: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme-images", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
setPluginAgentHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:agent-host-ready", ready),
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Netcatty example contributors
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.
+13
View File
@@ -0,0 +1,13 @@
# Hello World for Vault
A minimal browser-only example adapted from the Netcatty marketplace acceptance
example. It contributes one static Vault page and requests only `views`. It does
not access hosts, credentials, terminal, files, network, or user data.
This development example targets Netcatty `0.0.0` and API
`0.1.0-internal.1`; stable-release compatibility has not been established.
Import the package through Settings > Plugins, review it, enable it, and open
**Hello World** in the Vault Extensions group. Approve the view permission when
prompted. The page should display `Hello, world!`.
Based on the MIT-licensed [Netcatty plugin examples](https://github.com/binaricat/netcatty-plugin-examples).
+1
View File
@@ -0,0 +1 @@
export default { activate() {} };
@@ -0,0 +1,23 @@
{
"manifestVersion": 1,
"id": "com.netcatty.hello-vault",
"name": "hello-vault",
"displayName": { "en": "Hello World", "zh-CN": "你好,世界" },
"description": "A static Hello World page in Vault. No external data access.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "MIT",
"engines": { "netcatty": "=0.0.0", "api": "=0.1.0-internal.1" },
"features": { "required": ["views.vault-pages"] },
"main": { "browser": "index.js" },
"activationEvents": ["onView:com.netcatty.hello-vault.page"],
"permissions": { "required": ["views"], "optional": [] },
"contributes": {
"views": [{
"id": "com.netcatty.hello-vault.page",
"title": { "en": "Hello World", "zh-CN": "你好,世界" },
"location": "vault-page",
"entry": "page.html"
}]
}
}
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Hello World</title></head>
<body><main><h1>Hello, world!</h1><p>Hello from your Netcatty Vault plugin.</p></main></body>
</html>
Vendored
+1
View File
@@ -492,6 +492,7 @@ declare global {
getPluginAuthorPresentation?(request: NetcattyPluginAuthorRequest): Promise<NetcattyPluginAuthorPresentation | null>;
onPluginCatalogChanged?(callback: (event: { sourceId: string }) => void): () => void;
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
getPluginCatalogReadmeImages?(id: string, selectionToken?: string, sourceId?: string): Promise<Record<string, string>>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginAIHostReady?(ready: boolean): Promise<boolean>;
setPluginAgentHostReady?(ready: boolean): Promise<boolean>;