mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6e1b4a57 | ||
|
|
c6c3f27363 | ||
|
|
9e0a598765 | ||
|
|
7949242765 | ||
|
|
a0be25d107 |
No files matched your search
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Install from GitHub",
|
||||
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
|
||||
"settings.plugins.github.repository": "GitHub repository",
|
||||
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
|
||||
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
|
||||
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
|
||||
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
|
||||
"settings.plugins.github.confirm": "Install disabled",
|
||||
"settings.plugins.github.download": "Download and review",
|
||||
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
|
||||
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
|
||||
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
|
||||
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
|
||||
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
|
||||
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
|
||||
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
|
||||
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
|
||||
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
|
||||
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
|
||||
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
|
||||
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
|
||||
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
|
||||
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
|
||||
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
|
||||
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
|
||||
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
|
||||
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
|
||||
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
|
||||
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
|
||||
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
|
||||
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
|
||||
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
|
||||
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
|
||||
"settings.plugins.github.error.details": "Technical details",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Instalar desde GitHub",
|
||||
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
|
||||
"settings.plugins.github.repository": "Repositorio de GitHub",
|
||||
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
|
||||
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
|
||||
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
|
||||
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
|
||||
"settings.plugins.github.confirm": "Instalar desactivado",
|
||||
"settings.plugins.github.download": "Descargar y revisar",
|
||||
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
|
||||
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
|
||||
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
|
||||
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
|
||||
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
|
||||
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
|
||||
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
|
||||
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
|
||||
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
|
||||
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
|
||||
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
|
||||
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
|
||||
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
|
||||
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
|
||||
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
|
||||
"settings.plugins.github.error.details": "Detalles técnicos",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
|
||||
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
|
||||
import en from './en';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
|
||||
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
|
||||
assert.ok(keys.length > 30);
|
||||
for (const { id } of SUPPORTED_UI_LOCALES) {
|
||||
await loadLocaleMessages(id);
|
||||
const messages = getLoadedLocaleMessages(id)!;
|
||||
for (const key of keys) {
|
||||
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
|
||||
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
|
||||
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
|
||||
assert.equal(expired.key, 'settings.plugins.github.error.expired');
|
||||
assert.equal(expired.details, null);
|
||||
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
|
||||
const validator = 'manifest version must be a semantic version';
|
||||
assert.equal(describePluginGitHubInstallError(validator).details, validator);
|
||||
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
|
||||
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
|
||||
});
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Установить из GitHub",
|
||||
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
|
||||
"settings.plugins.github.repository": "Репозиторий GitHub",
|
||||
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
|
||||
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
|
||||
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
|
||||
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
|
||||
"settings.plugins.github.confirm": "Установить выключенным",
|
||||
"settings.plugins.github.download": "Загрузить и проверить",
|
||||
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
|
||||
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
|
||||
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
|
||||
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
|
||||
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
|
||||
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
|
||||
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
|
||||
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
|
||||
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
|
||||
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
|
||||
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
|
||||
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
|
||||
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
|
||||
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
|
||||
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
|
||||
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
|
||||
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
|
||||
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
|
||||
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
|
||||
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
|
||||
"settings.plugins.github.error.details": "Технические подробности",
|
||||
|
||||
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.cancel': 'Отмена',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "从 GitHub 安装",
|
||||
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
|
||||
"settings.plugins.github.repository": "GitHub 仓库",
|
||||
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
|
||||
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
|
||||
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
|
||||
"settings.plugins.github.confirm": "安装并保持禁用",
|
||||
"settings.plugins.github.download": "下载并审查",
|
||||
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
|
||||
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
|
||||
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
|
||||
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
|
||||
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
|
||||
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
|
||||
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
|
||||
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
|
||||
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
|
||||
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
|
||||
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
|
||||
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
|
||||
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
|
||||
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
|
||||
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
|
||||
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
|
||||
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
|
||||
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
|
||||
"settings.plugins.github.error.details": "技术详情",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "從 GitHub 安裝",
|
||||
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
|
||||
"settings.plugins.github.repository": "GitHub 儲存庫",
|
||||
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
|
||||
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
|
||||
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
|
||||
"settings.plugins.github.confirm": "安裝並保持停用",
|
||||
"settings.plugins.github.download": "下載並審查",
|
||||
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
|
||||
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
|
||||
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
|
||||
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
|
||||
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
|
||||
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
|
||||
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
|
||||
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
|
||||
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
|
||||
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
|
||||
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
|
||||
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
|
||||
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
|
||||
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
|
||||
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
|
||||
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
|
||||
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
|
||||
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
|
||||
"settings.plugins.github.error.details": "技術詳細資料",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
|
||||
|
||||
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
|
||||
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const token = useRef<string | null>(null);
|
||||
const active = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const reset = useCallback(() => {
|
||||
const previous = token.current;
|
||||
token.current = null;
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
const run = async (operation: () => Promise<void>) => {
|
||||
if (active.current) return;
|
||||
active.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await operation(); }
|
||||
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally { active.current = false; if (mounted.current) setBusy(false); }
|
||||
};
|
||||
return {
|
||||
preview, busy, error, reset,
|
||||
prepare: (input: string) => run(async () => {
|
||||
const { origin } = parsePluginGitHubRepository(input);
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
|
||||
reset();
|
||||
const result = await bridge.prepareGitHubPlugin(origin);
|
||||
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
|
||||
token.current = result.token;
|
||||
setPreview(result);
|
||||
}),
|
||||
install: () => run(async () => {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
|
||||
const selected = token.current;
|
||||
// The host consumes the selection even on failure; retry starts with a new review.
|
||||
token.current = null;
|
||||
setPreview(null);
|
||||
await bridge.installGitHubPlugin(selected);
|
||||
await onInstalled();
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3536,6 +3536,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
/** Submit compose blocks through the shell's advertised paste support. */
|
||||
preferBracketedPaste?: boolean;
|
||||
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
|
||||
focus?: boolean;
|
||||
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
|
||||
@@ -3584,7 +3586,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
// Compose submits a command block, so keep it in the shell's input buffer
|
||||
// even when clipboard bracketing is disabled. Otherwise a running command
|
||||
// can consume the later lines as stdin (#4062). Still require shell support.
|
||||
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
|
||||
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
@@ -2499,6 +2499,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (executor) {
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
preferBracketedPaste: true,
|
||||
// Preserve both prompt snapshots across an asynchronous wake: the
|
||||
// peer may clear its prompt classification before the actual write.
|
||||
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
|
||||
@@ -2538,7 +2539,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
// Explicitly local: an omitted broadcast option falls back to the
|
||||
// toolbar switch, which can still be on while compose is active-only.
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
preferBracketedPaste: true,
|
||||
})).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
|
||||
@@ -16,8 +16,8 @@ export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }:
|
||||
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
|
||||
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
|
||||
const sourceLabel = source?.kind === 'github' ? 'GitHub' : t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
|
||||
const sourceDescription = source?.kind === 'github' ? [source.origin, source.tag, source.commit].filter(Boolean).join(' · ') : 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;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
for (const locale of ['en', 'es', 'ru', 'zh-CN', 'zh-TW']) test(`real dialog (${locale}) reviews permissions and requires fresh trust after failed installation for same-package retries and different repositories`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { loadLocaleMessages, getLoadedLocaleMessages } = await import('../../../application/i18n/messages');
|
||||
await loadLocaleMessages(locale);
|
||||
const messages = getLoadedLocaleMessages(locale)!;
|
||||
const t = (key: string) => messages[key]!;
|
||||
const { PluginGitHubInstall } = await import('./PluginGitHubInstall');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const requests: string[] = [];
|
||||
const installed: string[] = [];
|
||||
const discarded: string[] = [];
|
||||
let refreshed = 0;
|
||||
let resolveDownload: ((value: NetcattyGitHubPluginPreview) => void) | undefined;
|
||||
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
|
||||
prepareGitHubPlugin: async (input: string) => { requests.push(input); if (requests.length === 1) throw new Error('GitHub API rate limit reached. Try again later'); return new Promise<NetcattyGitHubPluginPreview>(resolve => { resolveDownload = resolve; }); },
|
||||
installGitHubPlugin: async (token: string) => { installed.push(token); if (installed.length <= 2) throw new Error('GitHub package preview expired. Download and review it again'); return {} as NetcattyInstalledPlugin; },
|
||||
discardGitHubPlugin: async (token: string) => { discarded.push(token); },
|
||||
} as NetcattyBridge });
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
|
||||
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find(el => el.textContent === text)!;
|
||||
const click = async (el: HTMLElement) => { assert.ok(el); await act(async () => { el.click(); await settle(); }); };
|
||||
const type = async (text: string) => {
|
||||
await act(async () => {
|
||||
const input = document.querySelector<HTMLInputElement>('input:not([type=checkbox])')!;
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(input, text);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
});
|
||||
};
|
||||
const preview = { token: 'reviewed-token', source: { id: 'github:fixture', kind: 'github', name: 'owner/repo', origin: 'https://github.com/owner/repo', tag: 'v1.0.0', commit: 'a'.repeat(40) }, assetName: 'plugin.ncpkg', size: 400,
|
||||
archiveSha256: 'b'.repeat(64), manifest: { manifestVersion: 1, id: 'com.example.fixture', name: 'fixture', publisher: 'example', version: '1.0.0', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' }, permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: 'Read only fixture' }] } } } as NetcattyGitHubPluginPreview;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
const trigger = button(t('settings.plugins.github.install'));
|
||||
await click(trigger);
|
||||
assert.equal(document.activeElement?.tagName, 'INPUT');
|
||||
assert.ok(button(t('settings.plugins.github.download')).disabled);
|
||||
await type('https://evil.com/owner/repo');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
|
||||
assert.equal(requests.length, 0);
|
||||
if (locale !== 'en') {
|
||||
// The existing error follows provider state without repeating the host call.
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /repository only/);
|
||||
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
|
||||
assert.equal(requests.length, 0);
|
||||
}
|
||||
await type('github.com/Owner/Repo.git/');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.rateLimit'));
|
||||
assert.deepEqual(requests, ['https://github.com/owner/repo']);
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.ok(button(t('settings.plugins.github.download')).disabled);
|
||||
assert.equal(document.querySelector('form')?.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.disabled, true);
|
||||
await act(async () => { resolveDownload!(preview); await settle(); });
|
||||
const review = document.querySelector('[data-plugin-github-preview]')!;
|
||||
assert.match(review.textContent!, /https:\/\/github.com\/owner\/repo/);
|
||||
assert.match(review.textContent!, /v1\.0\.0.*aaaaaaaa/);
|
||||
assert.match(review.textContent!, /https:\/\/example.com/);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token']);
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
|
||||
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
|
||||
assert.equal(refreshed, 0);
|
||||
// A retry of the very same archive/repository is a fresh preview too.
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'retry-token' }); await settle(); });
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token']);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
|
||||
await type('another/plugin');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'different-token', source: { ...preview.source, origin: 'https://github.com/another/plugin', name: 'another/plugin' } }); await settle(); });
|
||||
assert.equal(requests.at(-1), 'https://github.com/another/plugin');
|
||||
assert.match(document.querySelector('[data-plugin-github-preview]')!.textContent!, /https:\/\/github.com\/another\/plugin/);
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token', 'retry-token']);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token', 'retry-token', 'different-token']);
|
||||
assert.equal(refreshed, 1);
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
await click(trigger);
|
||||
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, '');
|
||||
await type('owner/repo');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'cancel-token' }); await settle(); });
|
||||
await click(button(t('common.back')));
|
||||
assert.deepEqual(discarded, ['cancel-token']);
|
||||
await act(async () => { document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePluginGitHubInstall } from '../../../application/state/usePluginGitHubInstall';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
export function PluginGitHubInstall({ disabled, onInstalled }: { disabled: boolean; onInstalled: () => Promise<unknown> }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const flow = usePluginGitHubInstall(async () => { await onInstalled(); setOpen(false); });
|
||||
const preview = flow.preview;
|
||||
const error = flow.error ? describePluginGitHubInstallError(flow.error) : null;
|
||||
return <Dialog open={open} onOpenChange={(next) => {
|
||||
if (flow.busy) return;
|
||||
setOpen(next); flow.reset(); setAccepted(false); setInput('');
|
||||
}}>
|
||||
<DialogTrigger asChild><Button variant="outline" size="sm" disabled={disabled}><Github size={14} className="mr-2" />{t('settings.plugins.github.install')}</Button></DialogTrigger>
|
||||
<DialogContent hideCloseButton={flow.busy} className="app-no-drag max-h-[85dvh] overflow-y-auto sm:max-w-xl" data-plugin-github-install="">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.plugins.github.install')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.plugins.github.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" aria-busy={flow.busy} onSubmit={(event) => { event.preventDefault(); if (!flow.busy && input.trim() && !preview) void flow.prepare(input); }}>
|
||||
<label className="block space-y-2 text-sm"><span>{t('settings.plugins.github.repository')}</span>
|
||||
<Input autoFocus value={input} disabled={flow.busy || Boolean(preview)} maxLength={512} placeholder="https://github.com/owner/repo" onChange={(event) => setInput(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.formats')}</p>
|
||||
{flow.busy && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.github.busy')}</p>}
|
||||
{error && <div role="alert" className="break-words text-sm text-destructive">
|
||||
<p>{t(error.key, error.values)}</p>
|
||||
{error.details && <details className="mt-2 text-xs"><summary>{t('settings.plugins.github.error.details')}</summary><p className="whitespace-pre-wrap">{error.details}</p></details>}
|
||||
</div>}
|
||||
{preview && <div className="space-y-3" data-plugin-github-preview="">
|
||||
<p className="break-words text-sm font-medium">{preview.manifest.name} · {preview.manifest.version}</p>
|
||||
<div className="break-all rounded-md bg-muted/40 p-3 text-xs">
|
||||
<p>{preview.source.origin}</p><p>{preview.source.tag} · {preview.source.commit}</p>
|
||||
<p>{preview.assetName} · {preview.size.toLocaleString(resolvedLocale)} B</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.review')}</p>
|
||||
<PluginPackageDetails plugin={{ id: preview.manifest.id, enabled: false, activeVersion: preview.manifest.version, manifest: preview.manifest,
|
||||
archiveSha256: preview.archiveSha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />
|
||||
<label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={accepted} disabled={flow.busy} onChange={(event) => setAccepted(event.target.checked)} className="mt-1" />{t('settings.plugins.github.accept')}</label>
|
||||
</div>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={flow.busy} onClick={() => { if (preview) { flow.reset(); setAccepted(false); } else setOpen(false); }}>{t(preview ? 'common.back' : 'common.cancel')}</Button>
|
||||
{preview ? <Button type="button" disabled={flow.busy || !accepted} onClick={() => { setAccepted(false); void flow.install(); }}>{t('settings.plugins.github.confirm')}</Button>
|
||||
: <Button type="submit" disabled={flow.busy || !input.trim()}>{t('settings.plugins.github.download')}</Button>}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
{plugin.source?.kind === 'github' && <><dt>GitHub</dt><dd className="break-all">{plugin.source.origin}<br />{plugin.source.tag}<br /><span className="font-mono">{plugin.source.commit}</span></dd></>}
|
||||
</dl>
|
||||
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
import { PluginGitHubInstall } from './PluginGitHubInstall';
|
||||
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
@@ -35,6 +36,7 @@ function PluginManagementContent({ manager }: {
|
||||
<div className="flex items-center gap-2">
|
||||
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
|
||||
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
|
||||
<PluginGitHubInstall disabled={!manager.available || manager.busy} onInstalled={async () => { setPluginView('installed'); await manager.refresh(); }} />
|
||||
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1446,6 +1446,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
preferBracketedPaste: true,
|
||||
// Explicit target: active-only stays local while toolbar
|
||||
// broadcast (keystrokes) can remain on, and send-to-all
|
||||
// still fans out when the toolbar switch is off.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from '../../../lib/utils';
|
||||
import { AUTO_RUN_SNIPPET_LINE_DELAY_MS, shouldDelayAutoRunSnippetInput } from '../terminalHelpers';
|
||||
|
||||
// Exercise the shared production sender without mounting an Electron terminal.
|
||||
const source = readFileSync(new URL('../../Terminal.tsx', import.meta.url), 'utf8');
|
||||
const start = source.indexOf(' const executeSnippetCommand = useCallback(');
|
||||
const end = source.indexOf(' const executeSnippet = useCallback(', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send = executeSnippetCommand;', {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
type SendOptions = { preferBracketedPaste?: boolean; broadcast?: boolean; multiLineRunMode?: 'paste' | 'lineDelay' };
|
||||
function setup(bracketedPasteMode = true, disableBracketedPaste = true) {
|
||||
const writes: Array<{ id: string; data: string; lineDelayMs?: number }> = [];
|
||||
const broadcasts: string[] = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (command: string, noAutoRun?: boolean, options?: SendOptions) => Promise<boolean>,
|
||||
useCallback: (callback: unknown) => callback,
|
||||
termRef: { current: { modes: { bracketedPasteMode }, focus() {} } },
|
||||
sessionRef: { current: 'source' }, sessionId: 'source', hibernatedRef: { current: false },
|
||||
passwordPromptActiveRef: { current: false }, broadcastPasswordBypassRef: { current: false },
|
||||
isBroadcastEnabledRef: { current: false },
|
||||
shouldBroadcastDuringSensitivePrompt: () => true,
|
||||
isPromptSecurityAlternateScreenActive: () => false,
|
||||
onBroadcastInputRef: { current: (data: string) => { broadcasts.push(data); return ['peer']; } },
|
||||
normalizeLineEndings, wrapBracketedPaste, shouldDelayAutoRunSnippetInput, AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
disableBracketedPasteRef: { current: disableBracketedPaste },
|
||||
prepareProgrammaticSudoInput: (data: string) => data,
|
||||
scrollToBottomAfterProgrammaticInput() {}, markTerminalBroadcastUserInput() {},
|
||||
terminalBackend: { writeToSession: (id: string, data: string, options: { lineDelayMs?: number }) => {
|
||||
writes.push({ id, data, lineDelayMs: options.lineDelayMs });
|
||||
} },
|
||||
host: { protocol: 'ssh' }, serialConfig: { lineMode: false },
|
||||
};
|
||||
runInNewContext(code, context);
|
||||
return { send: context.send, writes, broadcasts };
|
||||
}
|
||||
|
||||
test('compose brackets multiline commands before the final Enter even when clipboard bracketing is disabled', async () => {
|
||||
const { send, writes, broadcasts } = setup();
|
||||
assert.equal(await send('echo one\r\necho two\necho three', false, {
|
||||
preferBracketedPaste: true, broadcast: true,
|
||||
}), true);
|
||||
assert.equal(writes[0].data, '\x1b[200~echo one\necho two\necho three\x1b[201~\r');
|
||||
assert.equal(writes[0].lineDelayMs, undefined);
|
||||
assert.deepEqual(broadcasts, [writes[0].data]);
|
||||
});
|
||||
|
||||
test('single-line compose and snippet paste/line-delay preferences stay unchanged', async () => {
|
||||
const { send, writes } = setup();
|
||||
await send('echo one', false, { preferBracketedPaste: true });
|
||||
await send('echo one\necho two', false);
|
||||
await send('echo one\necho two', true);
|
||||
await send('echo one\necho two', false, { multiLineRunMode: 'lineDelay' });
|
||||
assert.deepEqual(writes.map(({ data, lineDelayMs }) => ({ data, lineDelayMs })), [
|
||||
{ data: 'echo one\r', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two\r', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two\r', lineDelayMs: 250 },
|
||||
]);
|
||||
const unsupported = setup(false);
|
||||
await unsupported.send('echo one\necho two', false, { preferBracketedPaste: true });
|
||||
assert.equal(unsupported.writes[0].data.includes('\x1b[200~'), false);
|
||||
const enabled = setup(true, false);
|
||||
await enabled.send('echo one\necho two', false);
|
||||
assert.equal(enabled.writes[0].data, '\x1b[200~echo one\necho two\x1b[201~\r');
|
||||
});
|
||||
|
||||
// Python's standard-library PTY lets Bash/Readline exercise the actual terminal
|
||||
// bytes without depending on node-pty's Electron-specific native ABI.
|
||||
const bashPty = String.raw`
|
||||
import json, os, pty, select, signal, sys, time
|
||||
payload = json.load(sys.stdin)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvpe('bash', ['bash', '--noprofile', '--norc', '-i'],
|
||||
dict(os.environ, PS1='__COMPOSE_PROMPT__', TERM='xterm-256color',
|
||||
INPUTRC='/dev/null', HISTFILE='/dev/null', PROMPT_COMMAND=''))
|
||||
def prompt():
|
||||
output = b''
|
||||
deadline = time.monotonic() + 5
|
||||
while b'__COMPOSE_PROMPT__' not in output:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Bash prompt timeout: ' + repr(output))
|
||||
if select.select([fd], [], [], 0.1)[0]:
|
||||
output += os.read(fd, 65536)
|
||||
# Multiple unbracketed commands produce a prompt between each line. Drain
|
||||
# their output too, so the echo control does not stop at the first prompt.
|
||||
while time.monotonic() < deadline and select.select([fd], [], [], 0.1)[0]:
|
||||
output += os.read(fd, 65536)
|
||||
return output
|
||||
try:
|
||||
prompt()
|
||||
os.write(fd, b"stty -echo; bind 'set enable-bracketed-paste on'\r")
|
||||
prompt()
|
||||
os.write(fd, payload.encode())
|
||||
sys.stdout.write(prompt().decode(errors='replace'))
|
||||
finally:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.close(fd)
|
||||
os.waitpid(pid, 0)
|
||||
`;
|
||||
const hasBashPty = process.platform !== 'win32'
|
||||
&& spawnSync('python3', ['-c', 'import pty']).status === 0
|
||||
&& spawnSync('bash', ['-c', '(( BASH_VERSINFO[0] >= 5 ))']).status === 0;
|
||||
|
||||
for (const [name, command, expected] of [
|
||||
['all echo lines run', "echo '__ONE__'\necho '__TWO__'\necho '__THREE__'", ['__ONE__', '__TWO__', '__THREE__']],
|
||||
['long first command cannot consume later commands as stdin',
|
||||
"sleep 0.4; read -r -t 0.4 consumed; printf '__STDIN__:%s\\n' \"$consumed\"\necho '__TWO__'\necho '__THREE__'",
|
||||
['__STDIN__:', '__TWO__', '__THREE__']],
|
||||
['single-line still runs', "echo '__ONE__'", ['__ONE__']],
|
||||
] as const) {
|
||||
test(`Bash PTY compose: ${name}`, { skip: hasBashPty ? false : 'requires Python PTY support and Bash 5+' }, async () => {
|
||||
const { send, writes } = setup();
|
||||
await send(command, false, { preferBracketedPaste: true });
|
||||
const result = spawnSync('python3', ['-c', bashPty], {
|
||||
input: JSON.stringify(writes[0].data), encoding: 'utf8', timeout: 10000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || String(result.error));
|
||||
const lines = stripVTControlCharacters(result.stdout).split(/\r?\n/);
|
||||
assert.deepEqual(lines.filter(line => line.startsWith('__') && !line.startsWith('__COMPOSE_PROMPT__')), expected);
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,7 @@ export interface TerminalProps {
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
preferBracketedPaste?: boolean;
|
||||
focus?: boolean;
|
||||
},
|
||||
) => boolean | Promise<boolean>) | null,
|
||||
|
||||
@@ -112,6 +112,7 @@ export type SnippetExecutor = (
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
preferBracketedPaste?: boolean;
|
||||
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
|
||||
focus?: boolean;
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send =
|
||||
}).outputText;
|
||||
|
||||
type Status = 'connected' | 'connecting' | 'disconnected';
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean }) => boolean | Promise<boolean>;
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean; preferBracketedPaste?: boolean }) => boolean | Promise<boolean>;
|
||||
function setup(
|
||||
statuses: Status[],
|
||||
broadcast = false,
|
||||
@@ -96,6 +96,24 @@ test('compose send-to-all fans out while toolbar broadcast stays off', async ()
|
||||
assert.deepEqual(writes, ['0', '1']);
|
||||
});
|
||||
|
||||
for (const sendToAll of [false, true]) {
|
||||
test(`workspace compose requests supported bracketed paste on each target (all=${sendToAll})`, async () => {
|
||||
const calls: string[] = [];
|
||||
const text = 'echo one\necho two\necho three';
|
||||
const executors = new Map<string, Executor>(['0', '1'].map(id => [id, async (command, noAutoRun, options) => {
|
||||
assert.equal(command, text);
|
||||
assert.equal(noAutoRun, false);
|
||||
assert.equal(options?.preferBracketedPaste, true);
|
||||
assert.equal(options?.broadcast, false);
|
||||
calls.push(id);
|
||||
return true;
|
||||
}]));
|
||||
const { send } = setup(['connected', 'connected'], false, executors, new Set(), false, new Set(), sendToAll);
|
||||
assert.equal(await send(text), true);
|
||||
assert.deepEqual(calls, sendToAll ? ['0', '1'] : ['0']);
|
||||
});
|
||||
}
|
||||
|
||||
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
@@ -231,7 +249,8 @@ test('solo compose send uses the compose-bar target while toolbar broadcast stay
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean; preferBracketedPaste?: boolean }) => {
|
||||
assert.equal(options?.preferBracketedPaste, true);
|
||||
calls.push(options?.broadcast);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Install from GitHub
|
||||
|
||||
Settings > Plugins > Open plugin manager > **Install from GitHub** installs a
|
||||
published package from a public GitHub repository. Local `.ncpkg` import and
|
||||
marketplace installation remain available.
|
||||
|
||||
Accepted repository inputs are `https://github.com/owner/repo`,
|
||||
`github.com/owner/repo`, and `owner/repo`. HTTP is upgraded to HTTPS. An optional
|
||||
trailing `.git` and single `/` are removed. Case is normalized to lowercase, so
|
||||
all these forms share `https://github.com/owner/repo` and the same host-owned
|
||||
source ID. Credentials, explicit ports, other hosts/schemes, query/fragment,
|
||||
percent escapes, backslashes, traversal, and extra path segments are rejected.
|
||||
Paste a repository, not a branch, subdirectory, script or release asset URL.
|
||||
|
||||
## Author's release contract
|
||||
|
||||
The existing package contract is a `.ncpkg` ZIP produced by
|
||||
`@netcatty/plugin-cli`: root `netcatty.plugin.json`, its declared browser entry
|
||||
and resources, and any other validated package contents. The package validator
|
||||
is the authority for paths, manifest semantics, referenced files, CRCs, archive
|
||||
headers, expansion limits and companion integrity. A Git checkout, source ZIP,
|
||||
`package.json`, and `prepare.mjs` are not installable packages.
|
||||
|
||||
Existing local preparers remain author-side tools. For example,
|
||||
`examples/plugins/database-rfam-readonly/prepare.mjs` bundles `host.js` into
|
||||
`browser.js`, copies `netcatty.plugin.json`, and calls `buildPluginPackage`.
|
||||
The author must run this locally, validate the resulting `.ncpkg`, and attach
|
||||
it to a GitHub release. Netcatty never executes a downloaded preparer, npm
|
||||
lifecycle script, shell command, build tool or repository source.
|
||||
|
||||
There was no existing GitHub release discovery/naming contract on main. This
|
||||
flow defines the narrow delivery contract:
|
||||
|
||||
1. Resolve the exact public repository through GitHub's unauthenticated API.
|
||||
2. Read `/repos/{owner}/{repo}/releases/latest` (GitHub's latest published stable
|
||||
release, not a client-side semantic version search). Draft/prerelease-only
|
||||
repositories are unsupported.
|
||||
3. Require a nonempty tag and exactly one uploaded `.ncpkg` release asset. Its
|
||||
filename may be any ASCII name beginning with an alphanumeric character and
|
||||
otherwise containing letters, digits, `.`, `_`, `-`, at most 200 characters.
|
||||
There is no required repository-name prefix. Multiple assets require local
|
||||
import so the app cannot silently choose the wrong platform/package.
|
||||
4. Resolve the tag through `/repos/{owner}/{repo}/commits/{tag}` to a commit SHA.
|
||||
Download the asset's exact GitHub release URL, bound to that repository/tag.
|
||||
5. Check release size, optional GitHub `sha256:` digest, ZIP structure and the
|
||||
entire existing package validator, runtime admission, compatibility and
|
||||
retained source binding before offering a review.
|
||||
6. Show the actual package manifest/version, declared permission/resource
|
||||
bounds, source, tag/commit, asset and computed SHA-256. Require explicit trust
|
||||
acknowledgment. Install disabled through `PluginManager` and `PackageStore`;
|
||||
the immutable snapshot must match the reviewed digest and manifest.
|
||||
|
||||
The manifest is the package version authority; release tag naming is independent
|
||||
and both are displayed. The tag/commit identify release context, not proof that
|
||||
the binary was reproducibly built from that commit. The SHA-256 pins reviewed
|
||||
bytes; neither it nor GitHub ownership verifies a publisher signature.
|
||||
|
||||
No accessible repository, no stable release, no tag commit, missing/ambiguous
|
||||
asset, HTML/XML instead of a package, incompatible/invalid package, size/hash
|
||||
mismatch, access denial, rate limit and offline/HTTP failures all fail closed.
|
||||
There is no clone, auto-build, raw-file or registry fallback. Private repositories,
|
||||
login and tokens are unsupported. GitHub API repository moves require entering
|
||||
the current URL, rather than silently changing repository identity.
|
||||
|
||||
## Boundaries and persistence
|
||||
|
||||
Only the trusted host renderer can invoke prepare/install/discard management
|
||||
IPC. Plugin RPC exposes no GitHub retrieval or arbitrary URL fetch. The backend
|
||||
constructs the API URLs and allows only HTTPS `api.github.com`; API redirects
|
||||
are rejected. Release downloads allow HTTPS `github.com`,
|
||||
`release-assets.githubusercontent.com`, `objects.githubusercontent.com` with at
|
||||
most three redirects. Each redirect is checked before contacting its host.
|
||||
No cookies, Authorization headers, environment credentials or git configuration
|
||||
are consulted. Redirect signing queries are allowed only on these download
|
||||
hosts; repository inputs never accept queries or credentials.
|
||||
|
||||
Metadata is capped at 2 MiB; packages at the CLI's 100 MiB archive limit plus all
|
||||
existing expansion/file limits. The network sequence has a 90 second timeout;
|
||||
standalone bounded transfers default to 30 seconds. Streamed bodies are bounded
|
||||
regardless of Content-Length. Temporary archives use Netcatty's managed temp
|
||||
root and mode-0700 private directories, mode-0600 files. Previews are bound to the
|
||||
requesting renderer, expire after ten minutes, and are consumed on install or
|
||||
removed on cancel/window destruction. At most eight active/pending operations
|
||||
are retained. Installation revalidates the private snapshot and reviewed hash
|
||||
and manifest, then uses the existing atomic publication/recovery transaction.
|
||||
|
||||
The existing JSON source binding stores `kind: github`, a repository-derived
|
||||
source ID, canonical URL, tag, commit and asset ID without a schema migration.
|
||||
Private data ownership remains tied to the source ID. Replacing a local or
|
||||
marketplace installation requires the existing explicit uninstall/private-data
|
||||
reset. Installed details show the repository and pin after reopening/restart.
|
||||
To update, enter the same repository again and review its current release;
|
||||
registry update checks intentionally skip GitHub sources. No automatic GitHub
|
||||
update checks or downgrade policy are introduced.
|
||||
|
||||
The existing unsigned security principal still binds package bytes and publisher
|
||||
text, so changed unsigned code does not inherit grants. Normal installation
|
||||
admits browser-only/data-theme packages; advanced packages remain behind the
|
||||
existing developer gate. This feature does not add signing or grant advanced
|
||||
runtime trust. Review installed permission modes before enabling; declared
|
||||
supported permissions retain their existing default Allow/Ask/Deny behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
`domain/pluginGitHubRepository.test.cjs` checks the exact three input forms and
|
||||
identity equivalence. `electron/plugins/githubInstallService.test.cjs` exercises
|
||||
the real CLI-produced starter package and actual manager/store/database, plus
|
||||
controlled metadata/transfer failures. The renderer interaction test mounts the
|
||||
real Radix dialog. Existing package/manifest and runtime suites retain the
|
||||
archive abuse/companion/permission boundary checks.
|
||||
|
||||
Run the existing Electron smoke harness with this checkout built, the local SSH
|
||||
fixture running, and:
|
||||
|
||||
```sh
|
||||
NC_APP="$PWD" NC_OUT=/workspace/netcatty-smoke-shots/github-install/pass \
|
||||
NC_EXTRA="$PWD/scripts/plugin-github-install.smoke.mjs" \
|
||||
xvfb-run -a -s '-screen 0 1440x900x24' \
|
||||
node /workspace/netcatty-smoke-harness/smoke.mjs
|
||||
```
|
||||
|
||||
The extra uses Playwright on the actual app, a real starter `.ncpkg` and real
|
||||
loopback HTTP downloads through a main-process-only injected transport. Production
|
||||
URL checks, resolver, validator, preview, IPC, permissions and installation all
|
||||
run unchanged. The repository name/commit in this fixture are explicitly
|
||||
synthetic, not a claim of a public release. It records an MP4 of the display and
|
||||
actual Electron screenshots, rejects invalid input and a missing release, reviews
|
||||
permissions, installs disabled, changes a permission mode and reopens the manager
|
||||
to verify persistence. It never enables or executes the fixture plugin, and
|
||||
requires no user credential. The generic harness SSH login is its disposable
|
||||
local test account.
|
||||
@@ -0,0 +1,41 @@
|
||||
// Host errors cross Electron IPC as messages. Keep their presentation local to
|
||||
// this flow; never use translated strings to make authorization decisions.
|
||||
export function describePluginGitHubInstallError(message: string) {
|
||||
const rules: [RegExp, string][] = [
|
||||
[/^Use https:\/\/github\.com\/owner\/repo,/, 'invalidRepository'],
|
||||
[/^GitHub plugin installation unavailable$/, 'unavailable'],
|
||||
[/^Download and review a GitHub plugin first$/, 'reviewFirst'],
|
||||
[/^Cannot reach GitHub\./, 'network'],
|
||||
[/^GitHub download timed out$/, 'timeout'],
|
||||
[/^GitHub API rate limit reached\./, 'rateLimit'],
|
||||
[/^GitHub repository or asset not found\./, 'notFound'],
|
||||
[/^GitHub access denied\./, 'accessDenied'],
|
||||
[/^GitHub repository moved\./, 'moved'],
|
||||
[/^(GitHub download redirect|Too many GitHub download redirects)/, 'redirect'],
|
||||
[/^(This repository has no published stable release|Publish a stable GitHub release)/, 'noRelease'],
|
||||
[/^Release has no \.ncpkg asset\./, 'noAsset'],
|
||||
[/^Release contains multiple \.ncpkg assets\./, 'multipleAssets'],
|
||||
[/^(Invalid GitHub plugin artifact|GitHub release asset has no valid download URL|GitHub returned invalid release metadata)/, 'metadata'],
|
||||
[/^(GitHub did not return the selected public repository|GitHub asset does not belong|Invalid GitHub release identity)/, 'identity'],
|
||||
[/^The release tag has no accessible commit\./, 'commit'],
|
||||
[/^(GitHub download exceeds size limit|GitHub package size is invalid)/, 'size'],
|
||||
[/^GitHub package (size|SHA-256) mismatch$/, 'integrity'],
|
||||
[/^(Expected a \.ncpkg ZIP package|GitHub returned HTML\/XML|GitHub download has no body)/, 'package'],
|
||||
[/^A GitHub plugin operation is already active/, 'busy'],
|
||||
[/^GitHub package preview expired\./, 'expired'],
|
||||
[/^This package requires advanced developer mode/, 'advanced'],
|
||||
[/^Plugin is incompatible:/, 'incompatible'],
|
||||
[/^Plugin source differs\./, 'source'],
|
||||
];
|
||||
// invoke() may prepend its own channel/error wrapper to the original message.
|
||||
const original = message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '');
|
||||
const status = /^GitHub download failed \(HTTP (\d{3})\)$/.exec(original)?.[1];
|
||||
const kind = status ? 'http' : rules.find(([pattern]) => pattern.test(original))?.[1] ?? 'failed';
|
||||
return {
|
||||
key: `settings.plugins.github.error.${kind}`,
|
||||
values: { status },
|
||||
// Keep validator/compatibility diagnostics available without making the
|
||||
// primary error an untranslated host exception.
|
||||
details: kind === 'failed' || kind === 'incompatible' ? message : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
function parsePluginGitHubRepository(input) {
|
||||
const message = "Use https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths)";
|
||||
if (typeof input !== "string" || input.length > 512) throw new Error(message);
|
||||
const value = input.trim();
|
||||
// Match the original text, before URL normalization can hide traversal or ports.
|
||||
const match = /^(?:(?:https?:\/\/)?github\.com\/)?([a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?)\/([a-z0-9_.-]{1,100})\/?$/iu.exec(value);
|
||||
if (!match || match[1].includes("--")) throw new Error(message);
|
||||
const owner = match[1].toLowerCase();
|
||||
const repo = match[2].replace(/\.git$/iu, "").toLowerCase();
|
||||
if (!repo || repo === "." || repo === ".." || repo.endsWith(".git")) throw new Error(message);
|
||||
return Object.freeze({ owner, repo, repository: `${owner}/${repo}`, origin: `https://github.com/${owner}/${repo}` });
|
||||
}
|
||||
|
||||
module.exports = { parsePluginGitHubRepository };
|
||||
@@ -0,0 +1,23 @@
|
||||
"use strict";
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { parsePluginGitHubRepository: parse } = require('./pluginGitHubRepository.cjs');
|
||||
const { githubSource, assertSource } = require('../electron/plugins/sourceIdentity.cjs');
|
||||
|
||||
test('all Qi input forms, http, case and optional suffixes resolve to one repository and source identity', () => {
|
||||
const expected = { owner: 'owner', repo: 'repo', repository: 'owner/repo', origin: 'https://github.com/owner/repo' };
|
||||
for (const input of ['https://github.com/owner/repo', 'github.com/owner/repo', 'owner/repo', 'http://github.com/owner/repo.git/', 'HTTPS://GITHUB.COM/Owner/Repo/', ' Owner/Repo.git ']) {
|
||||
assert.deepEqual(parse(input), expected, input);
|
||||
assert.deepEqual(githubSource(input), githubSource(expected.origin), input);
|
||||
assert.deepEqual(assertSource(githubSource(input)), githubSource(expected.origin));
|
||||
}
|
||||
});
|
||||
|
||||
test('repository grammar rejects arbitrary hosts, scripts/assets, extra paths, normalization tricks and credentials', () => {
|
||||
for (const input of ['', null, {}, 'github.com', 'https://evil.com/owner/repo', 'git@github.com:owner/repo', 'ftp://github.com/owner/repo', '//github.com/owner/repo', 'https://github.com:443/owner/repo', 'https://user:pass@github.com/owner/repo', 'owner/repo?x', 'owner/repo#x', 'owner/repo/', 'owner/repo/tree/main', 'https://github.com/owner/repo/releases/download/v1/plugin.ncpkg', 'owner/repo/../../other', 'owner/%2e%2e', 'owner/..', 'owner/.git', 'owner/repo.git.git', 'ow--ner/repo', '-owner/repo', 'owner-/repo', 'owner/repo\\path', 'owner/repo\npath', 'javascript:alert(1)', 'https://github.com/owner/repo//']) {
|
||||
// A single trailing slash is intentionally accepted for shorthand as well.
|
||||
if (input === 'owner/repo/') { assert.equal(parse(input).repository, 'owner/repo'); continue; }
|
||||
assert.throws(() => parse(input), /Use https/, String(input));
|
||||
}
|
||||
assert.throws(() => assertSource({ ...githubSource('owner/repo'), id: 'official' }), /Invalid host/);
|
||||
});
|
||||
@@ -69,6 +69,7 @@ module.exports = {
|
||||
// fail-closed prompt classifier at runtime.
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
'domain/pluginRuntimePolicy.cjs',
|
||||
'domain/pluginGitHubRepository.cjs',
|
||||
'domain/pluginPermissionDescriptions.cjs',
|
||||
'domain/pluginThemePack.cjs',
|
||||
'domain/pluginRemoteTreePolicy.cjs',
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
|
||||
const { parsePluginGitHubRepository } = require("../../domain/pluginGitHubRepository.cjs");
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
const { githubSource } = require("./sourceIdentity.cjs");
|
||||
|
||||
const JSON_LIMIT = 2 * 1024 * 1024;
|
||||
const PREVIEW_TTL_MS = 10 * 60_000;
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
|
||||
function assertGitHubUrl(value, api = false) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port || url.hash
|
||||
|| /[\u0000-\u0020\u007f\\]/u.test(value)
|
||||
|| !(api ? url.hostname === "api.github.com" : DOWNLOAD_HOSTS.has(url.hostname))) {
|
||||
throw new Error("GitHub download redirected outside approved HTTPS hosts");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function fetchGitHubBytes(url, { limit, api = false, fetchImpl = fetch, signal = AbortSignal.timeout(30_000) }) {
|
||||
for (let redirects = 0; ; redirects++) {
|
||||
assertGitHubUrl(url, api);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, { redirect: "manual", credentials: "omit", referrerPolicy: "no-referrer", signal,
|
||||
headers: { Accept: api ? "application/vnd.github+json" : "application/octet-stream", "User-Agent": "Netcatty-Plugin-Installer" } });
|
||||
} catch (cause) { throw new Error(signal.aborted ? "GitHub download timed out" : "Cannot reach GitHub. Check your network connection and try again", { cause }); }
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
if (api) throw new Error("GitHub repository moved. Enter its current repository URL");
|
||||
if (redirects >= 3) throw new Error("Too many GitHub download redirects");
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error("GitHub download redirect has no destination");
|
||||
url = new URL(location, url).href;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
if (response.status === 429 || (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0")) throw new Error("GitHub API rate limit reached. Try again later");
|
||||
if (response.status === 404) {
|
||||
const error = new Error("GitHub repository or asset not found. Only accessible public repositories are supported; private repositories require local import");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if ([401, 403].includes(response.status)) throw new Error("GitHub access denied. Private repositories and login are not supported");
|
||||
throw new Error(`GitHub download failed (HTTP ${response.status})`);
|
||||
}
|
||||
if (Number(response.headers.get("content-length")) > limit) { await response.body?.cancel(); throw new Error("GitHub download exceeds size limit"); }
|
||||
if (/text\/html|(?:application|text)\/(?:[^;]*\+)?xml/iu.test(response.headers.get("content-type") ?? "")) {
|
||||
await response.body?.cancel(); throw new Error("GitHub returned HTML/XML instead of a plugin package or release metadata");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("GitHub download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("GitHub download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} catch (cause) {
|
||||
if (signal.aborted) throw new Error("GitHub download timed out", { cause });
|
||||
throw cause;
|
||||
} finally { await reader.cancel(); }
|
||||
}
|
||||
}
|
||||
|
||||
class GitHubInstallService {
|
||||
constructor(options) {
|
||||
this.manager = options.manager;
|
||||
// Injection is main-process/test-only. Neither renderer IPC nor plugin RPC can set a URL or transport.
|
||||
this.fetch = options.fetch ?? fetch;
|
||||
this.tempPath = options.getTempFilePath ?? getTempFilePath;
|
||||
this.previews = new Map();
|
||||
this.active = new Set();
|
||||
}
|
||||
|
||||
async discard(owner, token) {
|
||||
const pending = this.previews.get(owner);
|
||||
if (!pending || (token !== undefined && pending.preview.token !== token)) return;
|
||||
this.previews.delete(owner);
|
||||
clearTimeout(pending.timer);
|
||||
await fs.rm(pending.directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async prepare(owner, input) {
|
||||
const repository = parsePluginGitHubRepository(input);
|
||||
if (this.active.has(owner) || this.active.size + this.previews.size >= 8) throw new Error("A GitHub plugin operation is already active. Close the preview and try again");
|
||||
this.active.add(owner);
|
||||
let directory;
|
||||
try {
|
||||
await this.discard(owner);
|
||||
const signal = AbortSignal.timeout(90_000);
|
||||
const json = async (url) => {
|
||||
const bytes = await fetchGitHubBytes(url, { limit: JSON_LIMIT, api: true, fetchImpl: this.fetch, signal });
|
||||
try {
|
||||
const result = JSON.parse(bytes.toString("utf8"));
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Invalid metadata shape");
|
||||
return result;
|
||||
} catch { throw new Error("GitHub returned invalid release metadata (expected JSON object)"); }
|
||||
};
|
||||
const apiRoot = `https://api.github.com/repos/${repository.repository}`;
|
||||
const repo = await json(apiRoot);
|
||||
if (typeof repo.full_name !== "string" || repo.full_name.toLowerCase() !== repository.repository || repo.private !== false) throw new Error("GitHub did not return the selected public repository");
|
||||
let release;
|
||||
try { release = await json(`${apiRoot}/releases/latest`); }
|
||||
catch (error) {
|
||||
if (error.status === 404) throw new Error("This repository has no published stable release. Ask the author to publish a release with a .ncpkg asset, or import a built package locally");
|
||||
throw error;
|
||||
}
|
||||
if (release.draft || release.prerelease || typeof release.tag_name !== "string" || !release.tag_name || release.tag_name.length > 256
|
||||
|| /[\u0000-\u001f\u007f]/u.test(release.tag_name)) throw new Error("Publish a stable GitHub release with a tag and a .ncpkg asset first");
|
||||
const assets = Array.isArray(release.assets) ? release.assets.filter((asset) => typeof asset.name === "string" && /\.ncpkg$/iu.test(asset.name)) : [];
|
||||
if (assets.length !== 1) throw new Error(assets.length ? "Release contains multiple .ncpkg assets. Import the intended package locally" : "Release has no .ncpkg asset. Ask the author to build and attach one; source repositories cannot be built by Netcatty");
|
||||
const asset = assets[0];
|
||||
if (!/^[a-z0-9][a-z0-9._-]*\.ncpkg$/iu.test(asset.name) || asset.name.length > 200
|
||||
|| !Number.isSafeInteger(asset.id) || asset.id < 1 || asset.state !== "uploaded") throw new Error("Invalid GitHub plugin artifact name or identity");
|
||||
let download;
|
||||
try { download = new URL(asset.browser_download_url); }
|
||||
catch { throw new Error("GitHub release asset has no valid download URL"); }
|
||||
const expectedPath = `/${repository.repository}/releases/download/${encodeURIComponent(release.tag_name)}/${asset.name}`;
|
||||
const repositoryPath = `/${repository.repository}/`;
|
||||
if (download.origin !== "https://github.com" || !download.pathname.toLowerCase().startsWith(repositoryPath)
|
||||
|| download.pathname.slice(repositoryPath.length) !== expectedPath.slice(repositoryPath.length)
|
||||
|| download.search || download.hash || download.username || download.password) throw new Error("GitHub asset does not belong to the selected repository and release");
|
||||
let commit;
|
||||
try { commit = await json(`${apiRoot}/commits/${encodeURIComponent(release.tag_name)}`); }
|
||||
catch (error) {
|
||||
if (error.status === 404) throw new Error("The release tag has no accessible commit. Ask the author to repair the release tag");
|
||||
throw error;
|
||||
}
|
||||
const source = githubSource(repository.origin, { tag: release.tag_name, commit: commit.sha, assetId: asset.id });
|
||||
const cli = await import("@netcatty/plugin-cli");
|
||||
if (!Number.isSafeInteger(asset.size) || asset.size < 1 || asset.size > cli.PACKAGE_LIMITS.archiveBytes) throw new Error("GitHub package size is invalid or exceeds size limit");
|
||||
const bytes = await fetchGitHubBytes(download.href, { limit: cli.PACKAGE_LIMITS.archiveBytes, fetchImpl: this.fetch, signal });
|
||||
if (bytes.length !== asset.size) throw new Error("GitHub package size mismatch");
|
||||
if (!bytes.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) throw new Error("Expected a .ncpkg ZIP package; GitHub returned another file (possibly HTML/XML)");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (asset.digest != null && asset.digest !== `sha256:${sha256}`) throw new Error("GitHub package SHA-256 mismatch");
|
||||
directory = await fs.mkdtemp(this.tempPath("plugin-github-"));
|
||||
const archive = path.join(directory, "package.ncpkg");
|
||||
await fs.writeFile(archive, bytes, { flag: "wx", mode: 0o600 });
|
||||
const { manifest } = await cli.validatePluginPackage(archive);
|
||||
const store = this.manager.packageStore;
|
||||
if (!store.allowAdvancedPlugins) assertBrowserOnlyPlugin(manifest);
|
||||
const compatible = cli.checkPluginCompatibility(manifest, { netcattyVersion: store.netcattyVersion, apiVersion: store.apiVersion, features: store.supportedFeatures });
|
||||
if (!compatible.compatible) throw new Error(`Plugin is incompatible: ${compatible.errors.join("; ")}`);
|
||||
this.manager.database.assertInstallSource(manifest.id, source);
|
||||
if (owner?.isDestroyed?.()) throw new Error("Plugin management window closed");
|
||||
const preview = { token: randomUUID(), source, manifest, archiveSha256: sha256, assetName: asset.name, size: bytes.length };
|
||||
const timer = setTimeout(() => { void this.discard(owner, preview.token).catch(() => {}); }, PREVIEW_TTL_MS);
|
||||
timer.unref?.();
|
||||
this.previews.set(owner, { preview, directory, archive, timer });
|
||||
directory = null;
|
||||
return structuredClone(preview);
|
||||
} finally {
|
||||
this.active.delete(owner);
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async install(owner, token) {
|
||||
const pending = this.previews.get(owner);
|
||||
if (!pending || pending.preview.token !== token) throw new Error("GitHub package preview expired. Download and review it again");
|
||||
if (this.active.has(owner)) throw new Error("A GitHub plugin operation is already active");
|
||||
this.active.add(owner);
|
||||
clearTimeout(pending.timer);
|
||||
try {
|
||||
return await this.manager.install(pending.archive, { enable: false, source: pending.preview.source,
|
||||
expectedPackage: { sha256: pending.preview.archiveSha256, manifest: pending.preview.manifest } });
|
||||
} finally { this.active.delete(owner); await this.discard(owner, token); }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitHubInstallService, fetchGitHubBytes, assertGitHubUrl };
|
||||
@@ -0,0 +1,173 @@
|
||||
"use strict";
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { GitHubInstallService, fetchGitHubBytes, assertGitHubUrl } = require('./githubInstallService.cjs');
|
||||
const { PluginDatabase } = require('./database.cjs');
|
||||
const { PackageStore } = require('./packageStore.cjs');
|
||||
const { PluginManager } = require('./pluginManager.cjs');
|
||||
const { createPluginPaths } = require('./paths.cjs');
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'netcatty-github-test-'));
|
||||
const database = new PluginDatabase(createPluginPaths(root).database);
|
||||
t.after(async () => { database.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
const packageStore = new PackageStore({ paths: createPluginPaths(root), database, netcattyVersion: '0.0.0', apiVersion: '0.1.0-internal.1', supportedFeatures: ['metadata.brand-icon'], allowAdvancedPlugins: false });
|
||||
const manager = new PluginManager({ database, packageStore, runtimeSupervisor: { startEnabled() {}, stop() {}, start() {} } });
|
||||
await manager.initialize();
|
||||
const cli = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(root, 'fixture.ncpkg');
|
||||
// This is the real existing first-party example, packaged by the actual CLI.
|
||||
await cli.buildPluginPackage(path.resolve('examples/plugins/starter-netcatty'), archive);
|
||||
const bytes = await fs.readFile(archive);
|
||||
const digest = 'sha256:' + createHash('sha256').update(bytes).digest('hex');
|
||||
const asset = { id: 123, name: 'starter.ncpkg', state: 'uploaded', size: bytes.length, digest, browser_download_url: 'https://github.com/owner/repo/releases/download/v0.1.0/starter.ncpkg', ...options.asset };
|
||||
const release = { tag_name: 'v0.1.0', draft: false, prerelease: false, assets: [asset], ...options.release };
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
if (options.fetch) return options.fetch(url, init);
|
||||
if (url === 'https://api.github.com/repos/owner/repo') return Response.json({ full_name: 'Owner/Repo', private: false });
|
||||
if (url.endsWith('/releases/latest')) return Response.json(release);
|
||||
if (url.includes('/commits/')) return Response.json({ sha: 'a'.repeat(40) });
|
||||
if (url === asset.browser_download_url) return new Response(options.bytes ?? bytes);
|
||||
throw new Error('Unexpected fixture URL: ' + url);
|
||||
};
|
||||
const service = new GitHubInstallService({ manager, fetch: fetchImpl, getTempFilePath: (name) => path.join(root, name) });
|
||||
t.after(async () => { for (const owner of service.previews.keys()) await service.discard(owner); });
|
||||
return { root, manager, service, database, bytes, calls, cli, asset };
|
||||
}
|
||||
|
||||
test('actual example package downloads, previews permissions, installs disabled, survives recovery and retains repository binding', async t => {
|
||||
const f = await fixture(t);
|
||||
const owner = {};
|
||||
const preview = await f.service.prepare(owner, 'github.com/Owner/Repo.git/');
|
||||
assert.equal((await f.manager.list()).length, 0, 'review does not install');
|
||||
assert.equal(preview.source.origin, 'https://github.com/owner/repo');
|
||||
assert.equal(preview.source.commit, 'a'.repeat(40));
|
||||
assert.equal(preview.source.tag, 'v0.1.0');
|
||||
assert.equal(preview.manifest.id, 'com.netcatty.starter');
|
||||
assert.equal(f.calls.length, 4);
|
||||
assert.ok(f.calls.every(call => call.init.credentials === 'omit' && call.init.redirect === 'manual' && !call.init.headers.Authorization));
|
||||
await assert.rejects(f.service.install({}, preview.token), /expired/);
|
||||
const plugin = await f.service.install(owner, preview.token);
|
||||
assert.equal(plugin.enabled, false);
|
||||
assert.equal(plugin.archiveSha256, preview.archiveSha256);
|
||||
assert.deepEqual(plugin.source, preview.source);
|
||||
await f.manager.packageStore.recover();
|
||||
assert.deepEqual(f.database.getActivePlugin(plugin.id).source, preview.source);
|
||||
assert.equal(f.service.previews.size, 0);
|
||||
await assert.rejects(f.service.install(owner, preview.token), /expired/);
|
||||
await assert.rejects(f.manager.install(path.join(f.root, 'fixture.ncpkg'), { enable: false }), /source differs/);
|
||||
});
|
||||
|
||||
test('preview tampering, stale selections and cancellation cannot install unreviewed bytes', async t => {
|
||||
const f = await fixture(t);
|
||||
const owner = {};
|
||||
const first = await f.service.prepare(owner, 'owner/repo');
|
||||
const second = await f.service.prepare(owner, 'https://github.com/owner/repo');
|
||||
await assert.rejects(f.service.install(owner, first.token), /expired/);
|
||||
const pending = f.service.previews.get(owner);
|
||||
await fs.copyFile(path.join(f.root, 'fixture.ncpkg'), pending.archive);
|
||||
pending.preview.archiveSha256 = '0'.repeat(64);
|
||||
await assert.rejects(f.service.install(owner, second.token), /changed after preview/);
|
||||
assert.equal((await f.manager.list()).length, 0);
|
||||
const third = await f.service.prepare(owner, 'owner/repo');
|
||||
await f.service.discard(owner, third.token);
|
||||
await assert.rejects(f.service.install(owner, third.token), /expired/);
|
||||
});
|
||||
|
||||
test('release resolver rejects missing tags/artifacts, ambiguous packages and asset substitution without installing', async t => {
|
||||
for (const release of [{ tag_name: '' }, { prerelease: true }, { assets: [] }, { assets: [{ name: 'a.ncpkg' }, { name: 'b.ncpkg' }] }]) {
|
||||
await t.test(JSON.stringify(release), async t => {
|
||||
const f = await fixture(t, { release });
|
||||
await assert.rejects(f.service.prepare({}, 'owner/repo'), /release|assets|asset/);
|
||||
assert.equal((await f.manager.list()).length, 0);
|
||||
});
|
||||
}
|
||||
for (const asset of [{ name: '../evil.ncpkg' }, { browser_download_url: 'https://github.com/another/repo/releases/download/v0.1.0/starter.ncpkg' }, { size: 999999999 }, { digest: 'sha256:' + '0'.repeat(64) }]) {
|
||||
await t.test(JSON.stringify(asset), async t => {
|
||||
const f = await fixture(t, { asset });
|
||||
await assert.rejects(f.service.prepare({}, 'owner/repo'), /artifact|belong|size|SHA-256/);
|
||||
assert.equal(f.service.previews.size, 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('HTML/XML and invalid ZIP fail real package validation', async t => {
|
||||
for (const bytes of [Buffer.from('<html>not a plugin</html>'), Buffer.from('<?xml version="1.0"?><Error/>'), Buffer.from('PK\x03\x04corrupt')]) {
|
||||
await t.test(bytes.toString(), async t => {
|
||||
const digest = 'sha256:' + createHash('sha256').update(bytes).digest('hex');
|
||||
const f = await fixture(t, { bytes, asset: { size: bytes.length, digest } });
|
||||
await assert.rejects(f.service.prepare({}, 'owner/repo'), /package|zip|ZIP|archive|central|small/i);
|
||||
assert.equal((await f.manager.list()).length, 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('transport distinguishes offline, rate limit, inaccessible repository and server failure; rejects unsafe redirects and bounds streaming', async () => {
|
||||
const url = 'https://api.github.com/repos/owner/repo/releases/latest';
|
||||
const run = (fetchImpl, extra = {}) => fetchGitHubBytes(url, { limit: 10, api: true, fetchImpl, ...extra });
|
||||
await assert.rejects(run(async () => { throw new Error('offline'); }), /network connection/);
|
||||
for (const [status, expected] of [[404, /not found/], [401, /access denied/], [403, /access denied/], [429, /rate limit/], [500, /HTTP 500/]]) await assert.rejects(run(async () => new Response('', { status })), expected);
|
||||
await assert.rejects(run(async () => new Response('', { status: 403, headers: { 'x-ratelimit-remaining': '0' } })), /rate limit/);
|
||||
await assert.rejects(run(async () => new Response('', { status: 301, headers: { location: 'https://api.github.com/repos/another/repo' } })), /moved/);
|
||||
for (const destination of ['http://github.com/file', 'https://evil.com/file', 'https://github.com:444/file', 'https://user:pass@github.com/file', 'https://release-assets.githubusercontent.com.evil.com/file']) {
|
||||
await assert.rejects(fetchGitHubBytes('https://github.com/owner/repo/releases/download/v1/test.ncpkg', { limit: 10, fetchImpl: async () => new Response('', { status: 302, headers: { location: destination } }) }), /approved HTTPS/);
|
||||
}
|
||||
assert.equal(assertGitHubUrl('https://release-assets.githubusercontent.com/asset?signature=fixture'), 'https://release-assets.githubusercontent.com/asset?signature=fixture');
|
||||
await assert.rejects(run(async () => new Response('12345678901')), /size limit/);
|
||||
await assert.rejects(run(async () => new Response('', { headers: { 'content-length': '11' } })), /size limit/);
|
||||
await assert.rejects(run(async () => new Response('<html/>', { headers: { 'content-type': 'text/html' } })), /HTML\/XML/);
|
||||
await assert.rejects(run(async () => new Response('<Error/>', { headers: { 'content-type': 'application/xml' } })), /HTML\/XML/);
|
||||
const controller = new AbortController(); controller.abort();
|
||||
await assert.rejects(run(async () => { throw new Error('aborted'); }, { signal: controller.signal }), /timed out/);
|
||||
let redirects = 0;
|
||||
const bytes = await fetchGitHubBytes('https://github.com/owner/repo/releases/download/v1/test.ncpkg', { limit: 10, fetchImpl: async () => ++redirects === 1 ? new Response('', { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/asset?signature=fixture' } }) : new Response('PK') });
|
||||
assert.equal(bytes.toString(), 'PK');
|
||||
});
|
||||
|
||||
test('repository absence, missing stable release/tag commit, malformed metadata and transfer truncation stay distinct', async t => {
|
||||
for (const [which, expected] of [
|
||||
['repository', /repository or asset not found/], ['release', /no published stable release/], ['commit', /release tag has no accessible commit/],
|
||||
['json', /expected JSON object/], ['truncated', /size mismatch/], ['manifest', /changed after preview/],
|
||||
]) await t.test(which, async t => {
|
||||
const f = await fixture(t);
|
||||
if (which === 'manifest') {
|
||||
const owner = {};
|
||||
const preview = await f.service.prepare(owner, 'owner/repo');
|
||||
f.service.previews.get(owner).preview.manifest.publisher = 'unreviewed';
|
||||
await assert.rejects(f.service.install(owner, preview.token), expected);
|
||||
} else {
|
||||
const original = f.service.fetch;
|
||||
f.service.fetch = async (url, init) => {
|
||||
if ((which === 'repository' && url.endsWith('/owner/repo')) || (which === 'release' && url.endsWith('/latest')) || (which === 'commit' && url.includes('/commits/'))) return new Response('', { status: 404 });
|
||||
if (which === 'json' && url.endsWith('/latest')) return Response.json(null);
|
||||
if (which === 'truncated' && url.startsWith('https://github.com/')) return new Response(f.bytes.subarray(0, 30));
|
||||
return original(url, init);
|
||||
};
|
||||
await assert.rejects(f.service.prepare({}, 'owner/repo'), expected);
|
||||
}
|
||||
assert.equal((await f.manager.list()).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('CRC/content corruption is rejected even when the release size and SHA-256 match the downloaded ZIP', async t => {
|
||||
const f = await fixture(t);
|
||||
const damaged = Buffer.from(f.bytes);
|
||||
// Mutate stored file data, leaving all ZIP metadata and the manifest untouched.
|
||||
const marker = damaged.indexOf(Buffer.from('export'));
|
||||
assert.ok(marker > 0);
|
||||
damaged[marker] ^= 1;
|
||||
const original = f.service.fetch;
|
||||
f.service.fetch = async (url, init) => {
|
||||
if (url.endsWith('/latest')) return Response.json({ tag_name: 'v0.1.0', assets: [{ ...f.asset, digest: 'sha256:' + createHash('sha256').update(damaged).digest('hex') }] });
|
||||
if (url.startsWith('https://github.com/')) return new Response(damaged);
|
||||
return original(url, init);
|
||||
};
|
||||
await assert.rejects(f.service.prepare({}, 'owner/repo'), /CRC|checksum|integrity/i);
|
||||
assert.equal((await f.manager.list()).length, 0);
|
||||
});
|
||||
@@ -217,6 +217,12 @@ class PackageStore {
|
||||
const validation = await pluginCli.extractPluginPackage(archiveSnapshot, extractedDirectory);
|
||||
await syncDirectoryTree(extractedDirectory);
|
||||
const manifest = validation.manifest;
|
||||
if (options.expectedPackage) {
|
||||
const { isDeepStrictEqual } = require("node:util");
|
||||
if (snapshot.sha256 !== options.expectedPackage.sha256 || !isDeepStrictEqual(manifest, options.expectedPackage.manifest)) {
|
||||
throw new Error("Plugin package changed after preview; download and review it again");
|
||||
}
|
||||
}
|
||||
if (!this.allowAdvancedPlugins) assertBrowserOnlyPlugin(manifest);
|
||||
const compatibility = pluginCli.checkPluginCompatibility(manifest, {
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
|
||||
@@ -47,6 +47,9 @@ const CHANNELS = Object.freeze({
|
||||
author: "netcatty:plugins:author",
|
||||
catalogInstall: "netcatty:plugins:catalog-install",
|
||||
install: "netcatty:plugins:install",
|
||||
githubPrepare: "netcatty:plugins:github-prepare",
|
||||
githubInstall: "netcatty:plugins:github-install",
|
||||
githubDiscard: "netcatty:plugins:github-discard",
|
||||
setEnabled: "netcatty:plugins:set-enabled",
|
||||
restart: "netcatty:plugins:restart",
|
||||
uninstall: "netcatty:plugins:uninstall",
|
||||
@@ -224,6 +227,17 @@ function createTrustedPluginBridgeSender(options = {}) {
|
||||
|
||||
function registerPluginBridge(ipcMain, options) {
|
||||
const manager = options.manager;
|
||||
let githubService;
|
||||
const githubOwners = new WeakSet();
|
||||
const githubFor = (activeManager, event) => {
|
||||
const { GitHubInstallService } = require("./githubInstallService.cjs");
|
||||
githubService ??= new GitHubInstallService({ manager: activeManager });
|
||||
if (!githubOwners.has(event.sender)) {
|
||||
githubOwners.add(event.sender);
|
||||
event.sender.once?.("destroyed", () => { void githubService.discard(event.sender).catch(() => {}); });
|
||||
}
|
||||
return githubService;
|
||||
};
|
||||
const contributionService = options.contributionService;
|
||||
const terminalProviderService = options.terminalProviderService;
|
||||
const terminalDataPipelineService = options.terminalDataPipelineService;
|
||||
@@ -777,6 +791,9 @@ function registerPluginBridge(ipcMain, options) {
|
||||
payload?.archivePath,
|
||||
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
|
||||
));
|
||||
handle(CHANNELS.githubPrepare, (activeManager, payload, event) => githubFor(activeManager, event).prepare(event.sender, payload?.repository));
|
||||
handle(CHANNELS.githubInstall, (activeManager, payload, event) => githubFor(activeManager, event).install(event.sender, payload?.token));
|
||||
handle(CHANNELS.githubDiscard, (activeManager, payload, event) => githubFor(activeManager, event).discard(event.sender, payload?.token));
|
||||
handle(CHANNELS.setEnabled, async (activeManager, payload) => activeManager.setEnabled(
|
||||
payload?.pluginId,
|
||||
payload?.enabled === true,
|
||||
|
||||
@@ -1769,3 +1769,17 @@ test("policy edits require trusted UI and the exact source/version/policy revisi
|
||||
assert.equal(mode, "allow");
|
||||
engine.shutdown();
|
||||
});
|
||||
|
||||
test("GitHub management IPC rejects untrusted senders before initialization or retrieval", async () => {
|
||||
let initialized = 0;
|
||||
const ipcMain = createIpcMain();
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { initialize: async () => { initialized++; } },
|
||||
isTrustedSender: () => false,
|
||||
});
|
||||
for (const channel of [CHANNELS.githubPrepare, CHANNELS.githubInstall, CHANNELS.githubDiscard]) {
|
||||
assert.equal(typeof ipcMain.handlers.get(channel), 'function');
|
||||
await assert.rejects(ipcMain.handlers.get(channel)({ sender: {} }, { repository: 'owner/repo', token: 'unknown' }), /Untrusted plugin management sender/);
|
||||
}
|
||||
assert.equal(initialized, 0);
|
||||
});
|
||||
@@ -76,6 +76,7 @@ class PluginManager {
|
||||
enable: options?.enable,
|
||||
catalog: options?.catalog,
|
||||
source: options?.source,
|
||||
expectedPackage: options?.expectedPackage,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
options?.sourceGuard?.();
|
||||
if (!previousPlugin?.enabled) return;
|
||||
|
||||
@@ -12,6 +12,18 @@ const FIRST_PARTY_PLUGINS = Object.freeze([
|
||||
Object.freeze({ id: "com.netcatty.hello", repository: "binaricat/Netcatty" }),
|
||||
]);
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const { parsePluginGitHubRepository } = require("../../domain/pluginGitHubRepository.cjs");
|
||||
|
||||
function githubSource(address, pin = {}) {
|
||||
const { origin, repository } = parsePluginGitHubRepository(address);
|
||||
const source = { id: `github:${hash(origin)}`, kind: "github", name: repository, origin };
|
||||
if (Object.keys(pin).length) {
|
||||
if (typeof pin.tag !== "string" || !pin.tag || pin.tag.length > 256 || /[\u0000-\u001f\u007f]/u.test(pin.tag)
|
||||
|| !/^[a-f0-9]{40}$/u.test(pin.commit) || !Number.isSafeInteger(pin.assetId) || pin.assetId < 1) throw new Error("Invalid GitHub release identity");
|
||||
Object.assign(source, { tag: pin.tag, commit: pin.commit, assetId: pin.assetId });
|
||||
}
|
||||
return Object.freeze(source);
|
||||
}
|
||||
|
||||
// Source identity is host-owned. Neither a manifest nor a catalog supplies kind/id.
|
||||
function canonicalSourceOrigin(value) {
|
||||
@@ -36,7 +48,8 @@ function thirdPartySource(name, address) {
|
||||
|
||||
function assertSource(source) {
|
||||
if (!source || typeof source !== "object") throw new Error("Missing host source identity");
|
||||
const trusted = source.id === "official" ? OFFICIAL_SOURCE : source.id === "local" ? LOCAL_SOURCE : thirdPartySource(source.name, source.origin);
|
||||
const trusted = source.kind === "github" ? githubSource(source.origin, source.tag === undefined ? {} : { tag: source.tag, commit: source.commit, assetId: source.assetId })
|
||||
: source.id === "official" ? OFFICIAL_SOURCE : source.id === "local" ? LOCAL_SOURCE : thirdPartySource(source.name, source.origin);
|
||||
if (["id", "kind", "origin"].some((key) => trusted[key] !== source[key])) throw new Error("Invalid host source identity");
|
||||
return { ...trusted };
|
||||
}
|
||||
@@ -53,4 +66,4 @@ function isOfficialMaintained(entry, source) {
|
||||
&& FIRST_PARTY_PLUGINS.some((pair) => pair.id === entry.id && pair.repository === entry.repository);
|
||||
}
|
||||
|
||||
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner, isOfficialMaintained };
|
||||
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, githubSource, assertSource, privateOwner, isOfficialMaintained };
|
||||
@@ -147,6 +147,9 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
getPluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:permissions", { pluginId }),
|
||||
revokePluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:revoke-permissions", { pluginId }),
|
||||
prepareGitHubPlugin: (repository) => ipcRenderer.invoke("netcatty:plugins:github-prepare", { repository }),
|
||||
installGitHubPlugin: (token) => ipcRenderer.invoke("netcatty:plugins:github-install", { token }),
|
||||
discardGitHubPlugin: (token) => ipcRenderer.invoke("netcatty:plugins:github-discard", { token }),
|
||||
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
|
||||
archivePath,
|
||||
enable: options?.enable === true,
|
||||
|
||||
Vendored
+15
-1
@@ -386,10 +386,21 @@ declare global {
|
||||
|
||||
interface NetcattyPluginSource {
|
||||
id: string;
|
||||
kind: 'official' | 'third-party' | 'local';
|
||||
kind: 'official' | 'third-party' | 'local' | 'github';
|
||||
name: string;
|
||||
origin: string | null;
|
||||
enabled?: boolean;
|
||||
tag?: string;
|
||||
commit?: string;
|
||||
assetId?: number;
|
||||
}
|
||||
interface NetcattyGitHubPluginPreview {
|
||||
token: string;
|
||||
source: NetcattyPluginSource;
|
||||
manifest: import("@netcatty/plugin-contract").PluginManifest;
|
||||
archiveSha256: string;
|
||||
assetName: string;
|
||||
size: number;
|
||||
}
|
||||
interface NetcattyInstalledPlugin {
|
||||
source?: NetcattyPluginSource;
|
||||
@@ -513,6 +524,9 @@ declare global {
|
||||
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
|
||||
revokePluginPermissions?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
|
||||
prepareGitHubPlugin?(repository: string): Promise<NetcattyGitHubPluginPreview>;
|
||||
installGitHubPlugin?(token: string): Promise<NetcattyInstalledPlugin>;
|
||||
discardGitHubPlugin?(token: string): Promise<void>;
|
||||
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
|
||||
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
uninstallPlugin?(pluginId: string): Promise<boolean>;
|
||||
|
||||
@@ -69,3 +69,16 @@ test('a failing source leaves other fresh source results intact and missing APIs
|
||||
assert.equal(result.failedSources, 1); assert.equal(result.checked, 1); assert.equal(result.skipped, 1); assert.equal(result.updates[0].sourceId, official.id);
|
||||
await assert.rejects(checkPluginUpdates({}), /unavailable/);
|
||||
});
|
||||
|
||||
test('GitHub repository bindings remain skipped by registry update checks', async () => {
|
||||
const github: NetcattyPluginSource = { id: 'github:synthetic', kind: 'github', name: 'owner/repo', origin: 'https://github.com/owner/repo', tag: 'v1.0.0', commit: 'a'.repeat(40) };
|
||||
const installed = [plugin('com.example.github', '1.0.0', github)];
|
||||
const result = await checkPluginUpdates({
|
||||
async listPlugins() { return installed; },
|
||||
async listPluginSources() { return [github]; },
|
||||
async listPluginCatalog() { throw new Error('A repository is not a registry'); },
|
||||
});
|
||||
assert.equal(result.checked, 0);
|
||||
assert.equal(result.skipped, 1);
|
||||
assert.equal(result.failedSources, 0);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export async function checkPluginUpdates(bridge: UpdateBridge): Promise<PluginUp
|
||||
for (const plugin of installed) {
|
||||
const source = sources.find((item) => sameSource(plugin.source, item));
|
||||
// Local/unknown/removed/disabled bindings never fall back to another market.
|
||||
if (!plugin.activeVersion || !valid(plugin.activeVersion) || !source || source.kind === 'local' || source.enabled === false) continue;
|
||||
if (!plugin.activeVersion || !valid(plugin.activeVersion) || !source || source.kind === 'local' || source.kind === 'github' || source.enabled === false) continue;
|
||||
groups.set(source.id, [...(groups.get(source.id) ?? []), plugin]);
|
||||
}
|
||||
const checked = new Set<string>();
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
|
||||
"test:plugin-examples": "node --test examples/plugins/database-minimal/*.test.cjs examples/plugins/database-rfam-readonly/*.test.cjs examples/plugins/terminal-selection-draft/prepare.test.cjs examples/plugins/global-agent/*.test.cjs",
|
||||
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps && npm run build:plugin-view-examples",
|
||||
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs domain/pluginThemePack.test.cjs",
|
||||
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs domain/pluginThemePack.test.cjs domain/pluginGitHubRepository.test.cjs",
|
||||
"test:plugin-host-drawn": "node --test --import tsx domain/pluginRemoteTreePolicy.test.cjs domain/pluginViewStylePolicy.test.cjs electron/plugins/hostDrawnViewHost.test.cjs application/state/pluginRemoteTree.test.tsx components/plugins/PluginRemoteSurface.test.tsx packages/plugin-sdk/src/hostReact.test.tsx application/state/pluginViewSession.test.ts application/state/terminalSidePanelPluginTabs.test.tsx components/PluginVaultPages.test.tsx components/PluginTerminalSidebar.test.tsx electron/plugins/viewResourceService.test.cjs",
|
||||
"pretest:plugin-runtime:electron": "npm run build:plugin-runtime-deps",
|
||||
"test:plugin-runtime:electron": "electron electron/plugins/electronRuntime.live.test.cjs",
|
||||
|
||||
@@ -49,6 +49,14 @@ async function checkDevImport() {
|
||||
const settingsResponse = await fetch(new URL("/components/settings/tabs/SettingsPluginsTab.tsx", origin));
|
||||
assert.equal(settingsResponse.status, 200);
|
||||
assert.match(await settingsResponse.text(), /\/components\/settings\/tabs\/PluginPackageManager\.tsx/);
|
||||
const hookResponse = await fetch(new URL("/application/state/usePluginGitHubInstall.ts", origin));
|
||||
assert.equal(hookResponse.status, 200);
|
||||
const parserImport = (await hookResponse.text()).match(/^import\s+.*from "[^"\n]*pluginGitHubRepository[^"\n]*";[^\n]*$/m);
|
||||
assert.ok(parserImport, "Missing Vite-served GitHub repository parser import");
|
||||
const parserProbe = new SourceTextModule(`${parserImport[0]}\nexport { parsePluginGitHubRepository };`, { context, identifier: new URL("/github-parser-probe", origin).href });
|
||||
await parserProbe.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
await parserProbe.evaluate();
|
||||
assert.equal(parserProbe.namespace.parsePluginGitHubRepository("github.com/Owner/Repo.git/").origin, "https://github.com/owner/repo");
|
||||
assert.match(managerSource, /\/components\/settings\/tabs\/PluginPermissionPanel\.tsx/);
|
||||
const panelResponse = await fetch(new URL("/components/settings/tabs/PluginPermissionPanel.tsx", origin));
|
||||
assert.equal(panelResponse.status, 200);
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// NC_EXTRA for the existing real Electron smoke harness. No product test switch.
|
||||
// Uses the actual starter .ncpkg and a host-only transport injection to a loopback fixture.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { buildPluginPackage } from '../packages/plugin-cli/dist/index.js';
|
||||
|
||||
export default async function githubInstallSmoke({ app, win, log, OUT }) {
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
const archive = path.join(OUT, 'starter-fixture.ncpkg');
|
||||
await buildPluginPackage(path.join(repoRoot, 'examples/plugins/starter-netcatty'), archive);
|
||||
const bytes = await fs.readFile(archive);
|
||||
const digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
||||
const locale = process.env.NC_GITHUB_LOCALE || 'en';
|
||||
assert.ok(['en', 'zh-CN', 'zh-TW'].includes(locale));
|
||||
const labels = {
|
||||
en: { plugins: 'Plugins', browse: 'Browse', install: 'Install from GitHub', repository: 'GitHub repository', confirm: 'Install disabled', back: 'Back', invalid: 'repository only', noRelease: 'no published stable release', expired: 'preview expired', ask: 'Ask', trust: 'I trust this repository', review: 'No build scripts run' },
|
||||
'zh-CN': { plugins: '插件', browse: '浏览', install: '从 GitHub 安装', repository: 'GitHub 仓库', confirm: '安装并保持禁用', back: '返回', invalid: '请只填写仓库地址', noRelease: '尚未发布稳定版本', expired: '预览已失效', ask: '询问', trust: '已审查并信任此仓库', review: '安装过程不会运行构建脚本' },
|
||||
'zh-TW': { plugins: '外掛程式', browse: '瀏覽', install: '從 GitHub 安裝', repository: 'GitHub 儲存庫', confirm: '安裝並保持停用', back: '返回', invalid: '請只填寫儲存庫位址', noRelease: '尚未發布穩定版本', expired: '預覽已失效', ask: '詢問', trust: '已審查並信任此儲存庫', review: '安裝過程不會執行建置腳本' },
|
||||
}[locale];
|
||||
const repository = 'netcatty-smoke/starter-plugin';
|
||||
const rejectedRepository = 'netcatty-smoke/rejected-plugin';
|
||||
const tag = 'v0.1.3';
|
||||
const requests = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
requests.push(req.url);
|
||||
await new Promise(resolve => setTimeout(resolve, 300)); // Make the real busy state visible.
|
||||
const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };
|
||||
if (req.url === '/repos/netcatty-smoke/no-release') return send(200, { full_name: 'netcatty-smoke/no-release', private: false });
|
||||
if (req.url === '/repos/netcatty-smoke/no-release/releases/latest') return send(404, { message: 'No release' });
|
||||
for (const selected of [repository, rejectedRepository, 'owner/repo']) {
|
||||
if (req.url === `/repos/${selected}`) return send(200, { full_name: selected, private: false });
|
||||
if (req.url === `/repos/${selected}/releases/latest`) return send(200, { tag_name: tag, draft: false, prerelease: false, assets: [{ id: 123, state: 'uploaded', name: 'starter.ncpkg', size: bytes.length, digest, browser_download_url: `https://github.com/${selected}/releases/download/${tag}/starter.ncpkg` }] });
|
||||
if (req.url === `/repos/${selected}/commits/${tag}`) return send(200, { sha: 'a'.repeat(40) });
|
||||
if (req.url === `/${selected}/releases/download/${tag}/starter.ncpkg`) { res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': bytes.length }); res.end(bytes); return; }
|
||||
}
|
||||
send(404, { message: 'Unknown controlled fixture path' });
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const fixtureOrigin = `http://127.0.0.1:${server.address().port}`;
|
||||
let video;
|
||||
let sw;
|
||||
try {
|
||||
// Trusted main-process automation changes only the network boundary. The UI,
|
||||
// parser, resolver, validation, preview tokens, IPC and installation remain real.
|
||||
await app.evaluate((_, origin) => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
if (['api.github.com', 'github.com'].includes(url.hostname)) return original(`${origin}${url.pathname}`, init);
|
||||
return original(input, init);
|
||||
};
|
||||
}, fixtureOrigin);
|
||||
const windowPromise = app.waitForEvent('window');
|
||||
await win.evaluate(() => window.netcatty.openSettingsWindow());
|
||||
sw = await windowPromise;
|
||||
const rendererErrors = [];
|
||||
sw.on('pageerror', error => rendererErrors.push(String(error)));
|
||||
sw.on('console', message => { if (message.type() === 'error') log(`GitHub settings console: ${message.text()}`); });
|
||||
await sw.waitForLoadState('domcontentloaded');
|
||||
// Use the actual Settings language selector and its existing persisted state.
|
||||
if (locale !== 'en') {
|
||||
await sw.getByRole('tab', { name: 'Appearance', exact: true }).click();
|
||||
const language = sw.locator('[data-settings-anchor="appearance-language"] button[role="combobox"]');
|
||||
await language.click();
|
||||
await sw.getByRole('option', { name: locale === 'zh-CN' ? '简体中文' : '繁體中文', exact: true }).click();
|
||||
}
|
||||
await sw.waitForFunction(expected => document.documentElement.lang === expected, locale);
|
||||
await sw.getByRole('tab', { name: labels.plugins, exact: true }).click();
|
||||
await sw.getByRole('button', { name: labels.browse, exact: true }).click();
|
||||
const shot = async name => {
|
||||
await sw.screenshot({ path: path.join(OUT, `${name}.png`) });
|
||||
log(`GitHub screenshot ${name}`);
|
||||
await sw.waitForTimeout(900);
|
||||
};
|
||||
video = spawn('ffmpeg', ['-y', '-f', 'x11grab', '-video_size', '1440x900', '-framerate', '15', '-i', process.env.DISPLAY, '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', path.join(OUT, 'github-install.mp4')], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
const videoErrors = [];
|
||||
video.stderr.on('data', data => videoErrors.push(String(data)));
|
||||
video.on('error', error => videoErrors.push(String(error)));
|
||||
await shot('github-manager');
|
||||
await sw.getByRole('button', { name: labels.install, exact: true }).click();
|
||||
const dialog = sw.locator('[data-plugin-github-install]');
|
||||
const input = dialog.getByRole('textbox', { name: labels.repository });
|
||||
await input.fill('https://evil.example/owner/repo');
|
||||
await input.press('Enter');
|
||||
await dialog.getByRole('alert').filter({ hasText: labels.invalid }).waitFor();
|
||||
assert.equal(requests.length, 0);
|
||||
await shot('github-invalid-input');
|
||||
await input.fill('github.com/netcatty-smoke/no-release');
|
||||
await input.press('Enter');
|
||||
await dialog.getByRole('alert').filter({ hasText: labels.noRelease }).waitFor();
|
||||
await shot('github-no-release');
|
||||
const trustChecks = [];
|
||||
const inputFormats = [];
|
||||
for (const [index, value] of ['https://github.com/owner/repo', 'github.com/owner/repo', 'owner/repo'].entries()) {
|
||||
await input.fill(value);
|
||||
await shot(`github-format-${index + 1}-input`);
|
||||
const requestStart = requests.length;
|
||||
await input.press('Enter');
|
||||
const reviewed = dialog.locator('[data-plugin-github-preview]');
|
||||
await reviewed.waitFor({ timeout: 30000 });
|
||||
assert.ok((await reviewed.innerText()).includes('https://github.com/owner/repo'));
|
||||
assert.ok((await reviewed.innerText()).includes(labels.review));
|
||||
await dialog.getByText(labels.trust, { exact: false }).waitFor();
|
||||
assert.equal(await dialog.getByRole('checkbox').isChecked(), false);
|
||||
assert.equal(await dialog.getByRole('button', { name: labels.confirm, exact: true }).isDisabled(), true);
|
||||
assert.deepEqual(requests.slice(requestStart), ['/repos/owner/repo', '/repos/owner/repo/releases/latest', `/repos/owner/repo/commits/${tag}`, `/owner/repo/releases/download/${tag}/starter.ncpkg`]);
|
||||
inputFormats.push({ input: value, canonicalOrigin: 'https://github.com/owner/repo', realPreview: true, requests: requests.slice(requestStart), unchecked: true, installDisabled: true });
|
||||
await shot(`github-format-${index + 1}-preview`);
|
||||
await dialog.getByRole('button', { name: labels.back, exact: true }).click();
|
||||
await reviewed.waitFor({ state: 'hidden' });
|
||||
}
|
||||
for (const attempt of ['first', 'same-package-retry']) {
|
||||
await input.fill(rejectedRepository);
|
||||
await input.press('Enter');
|
||||
await dialog.locator('[data-plugin-github-preview]').waitFor({ timeout: 30000 });
|
||||
const confirm = dialog.getByRole('button', { name: labels.confirm, exact: true });
|
||||
const checkbox = dialog.getByRole('checkbox');
|
||||
assert.equal(await checkbox.isChecked(), false);
|
||||
assert.equal(await confirm.isDisabled(), true);
|
||||
trustChecks.push({ attempt, checked: false, installDisabled: true });
|
||||
await checkbox.check();
|
||||
// Use the real host cleanup path to invalidate the downloaded preview,
|
||||
// just as expiry does. Never replace install IPC or fake its response.
|
||||
await sw.evaluate(() => window.netcatty.discardGitHubPlugin());
|
||||
await confirm.click();
|
||||
await dialog.getByRole('alert').filter({ hasText: labels.expired }).waitFor();
|
||||
assert.equal(await dialog.locator('[data-plugin-github-preview]').count(), 0);
|
||||
assert.deepEqual(await sw.evaluate(() => window.netcatty.listPlugins()), []);
|
||||
await shot(`github-rejected-${attempt}`);
|
||||
log(`GitHub real host rejection after download: ${attempt}; no installed package`);
|
||||
}
|
||||
await input.fill('https://github.com/netcatty-smoke/starter-plugin.git/');
|
||||
await shot('github-input');
|
||||
await input.press('Enter');
|
||||
await dialog.getByRole('status').waitFor();
|
||||
await shot('github-downloading');
|
||||
const preview = dialog.locator('[data-plugin-github-preview]');
|
||||
await preview.waitFor({ timeout: 30000 });
|
||||
assert.match(await preview.innerText(), /https:\/\/github.com\/netcatty-smoke\/starter-plugin/);
|
||||
assert.match(await preview.innerText(), /v0\.1\.3/);
|
||||
assert.match(await preview.innerText(), /SHA-256/);
|
||||
const confirm = dialog.getByRole('button', { name: labels.confirm, exact: true });
|
||||
assert.equal(await confirm.isDisabled(), true);
|
||||
assert.equal(await dialog.getByRole('checkbox').isChecked(), false);
|
||||
trustChecks.push({ attempt: 'different-repository', checked: false, installDisabled: true });
|
||||
await shot('github-review-permissions');
|
||||
await confirm.scrollIntoViewIfNeeded();
|
||||
await shot('github-permission-confirmation');
|
||||
await dialog.getByRole('checkbox').check();
|
||||
await confirm.click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
const installed = await sw.evaluate(() => window.netcatty.listPlugins());
|
||||
assert.equal(installed.length, 1);
|
||||
assert.equal(installed[0].enabled, false);
|
||||
assert.equal(installed[0].source.origin, `https://github.com/${repository}`);
|
||||
assert.equal(installed[0].archiveSha256, digest.slice(7));
|
||||
await shot('github-installed');
|
||||
const displayName = installed[0].manifest.displayName;
|
||||
const pluginName = typeof displayName === 'string' ? displayName : displayName?.[locale] ?? displayName?.en ?? installed[0].manifest.name;
|
||||
await sw.getByRole('button', { name: pluginName, exact: true }).click();
|
||||
await sw.locator('[data-host-security-surface]').waitFor();
|
||||
const modes = sw.locator('[data-host-security-surface]').getByRole('combobox');
|
||||
await modes.first().waitFor();
|
||||
await modes.first().click();
|
||||
await sw.getByRole('option', { name: labels.ask, exact: true }).click();
|
||||
await sw.getByRole('combobox').filter({ hasText: labels.ask }).waitFor();
|
||||
await modes.first().scrollIntoViewIfNeeded();
|
||||
await shot('github-installed-permission-mode');
|
||||
await sw.keyboard.press('Escape');
|
||||
await sw.getByRole('button', { name: labels.browse, exact: true }).waitFor();
|
||||
await sw.getByRole('button', { name: labels.browse, exact: true }).click();
|
||||
await sw.getByRole('button', { name: pluginName, exact: true }).click();
|
||||
await sw.locator('[data-host-security-surface]').getByRole('combobox').filter({ hasText: labels.ask }).waitFor();
|
||||
assert.match(await sw.locator('[data-plugin-detail]').innerText(), /https:\/\/github.com\/netcatty-smoke\/starter-plugin/);
|
||||
await shot('github-reopened-source');
|
||||
await sw.waitForTimeout(1000);
|
||||
log(`GitHub install smoke PASS: ${requests.length} controlled HTTP requests; validated actual starter .ncpkg; no plugin enabled`);
|
||||
assert.deepEqual(rendererErrors, []);
|
||||
await fs.writeFile(path.join(OUT, 'github-fixture-evidence.json'), JSON.stringify({ locale, inputFormats, fixture: repository, controlledLoopback: true, requests, sha256: digest.slice(7), trustChecks, realPreviewRejections: 2, rendererErrors, installed, videoErrors: videoErrors.slice(-3) }, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (sw) {
|
||||
await sw.screenshot({ path: path.join(OUT, 'github-failure.png') }).catch(() => {});
|
||||
await fs.writeFile(path.join(OUT, 'github-failure-dom.txt'), await sw.locator('body').innerText().catch(() => 'unavailable'));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (video && video.exitCode === null) { const stopped = new Promise(resolve => video.once('exit', resolve)); video.kill('SIGINT'); await stopped; }
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -111,7 +111,7 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
// The shared runtime is also required by Electron; convert it for browser builds.
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy)\.cjs$/],
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
},
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
@@ -135,6 +135,7 @@ export default defineConfig(() => {
|
||||
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
|
||||
'@/domain/systemManager/containerRuntime.shared.cjs',
|
||||
'@/domain/pluginRuntimePolicy.cjs',
|
||||
'@/domain/pluginGitHubRepository.cjs',
|
||||
'@/domain/pluginPermissionDescriptions.cjs',
|
||||
'@/domain/pluginThemePack.cjs',
|
||||
'@/domain/pluginRemoteTreePolicy.cjs',
|
||||
|
||||
Reference in new issue
Block a user