mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 09:24:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb21936978 |
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "管理来源",
|
||||
|
||||
@@ -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": "管理来源",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, /<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>
|
||||
|
||||
@@ -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, /<script>/);
|
||||
});
|
||||
|
||||
test('README only renders registered local rasterized images and placeholders otherwise', () => {
|
||||
const path = `/assets/${'a'.repeat(64)}.png`;
|
||||
const html = renderToStaticMarkup(<PluginReadme markdown={`  `} 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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"; },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user