mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6e1b4a57 | ||
|
|
c6c3f27363 | ||
|
|
9e0a598765 | ||
|
|
7949242765 | ||
|
|
a0be25d107 | ||
|
|
3e454a84a5 | ||
|
|
a70e21d39b | ||
|
+1 |
16061720e6 |
No files matched your search
@@ -21,13 +21,20 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
});
|
||||
|
||||
const {
|
||||
applyLegacySyncPayload,
|
||||
convergentConflictAddressKey,
|
||||
createConvergentSyncStateFromPayload,
|
||||
dotKey,
|
||||
mergeConvergentSyncStates,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
stripConvergentSyncEnvelope,
|
||||
} = await import('../domain/convergentSync/index.ts');
|
||||
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
||||
const {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
resolvePreparedConvergentMigrationConflict,
|
||||
} = await import('./convergentSyncMigration.ts');
|
||||
|
||||
function payload(): SyncPayload {
|
||||
@@ -44,6 +51,42 @@ test.beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
});
|
||||
|
||||
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
|
||||
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
|
||||
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||
const state = mergeConvergentSyncStates(
|
||||
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
|
||||
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
|
||||
);
|
||||
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
|
||||
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
|
||||
}), state);
|
||||
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
|
||||
const conflict = plan.preview.conflicts[0];
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
|
||||
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
|
||||
const resolved = resolvePreparedConvergentMigrationConflict(
|
||||
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
|
||||
);
|
||||
|
||||
assert.equal(resolved.plan.preview.canInitialize, true);
|
||||
assert.equal(resolved.plan.preview.conflictCount, 0);
|
||||
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
|
||||
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
|
||||
assert.ok(resolved.plan.state?.vector.resolver);
|
||||
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
|
||||
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
|
||||
assert.equal(prepared.plan.preview.conflictCount, 1);
|
||||
assert.equal(prepared.plan.payload, null);
|
||||
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||
prepared, 'missing', dotKey(selected.dot), manager,
|
||||
), /conflict no longer exists/);
|
||||
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||
prepared, addressKey, 'missing', manager,
|
||||
), /candidate no longer exists/);
|
||||
});
|
||||
|
||||
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
||||
const remotePayload: SyncPayload = {
|
||||
...payload(),
|
||||
|
||||
@@ -6,8 +6,11 @@ import type {
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
cloudSyncPayloadsEqual,
|
||||
convergentConflictAddressKey,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
resolveConvergentFieldConflict,
|
||||
stripConvergentSyncEnvelope,
|
||||
validateConvergentSyncPayload,
|
||||
type ConvergentMigrationPlan,
|
||||
@@ -25,6 +28,25 @@ export interface PreparedConvergentMigration {
|
||||
localSnapshot: SyncPayload;
|
||||
}
|
||||
|
||||
export function resolvePreparedConvergentMigrationConflict(
|
||||
prepared: PreparedConvergentMigration,
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): PreparedConvergentMigration {
|
||||
const conflict = prepared.plan.preview.conflicts.find(
|
||||
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
|
||||
);
|
||||
if (!prepared.plan.state || !conflict) {
|
||||
throw new Error('The convergent migration conflict no longer exists');
|
||||
}
|
||||
const state = resolveConvergentFieldConflict(
|
||||
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
|
||||
);
|
||||
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
|
||||
}
|
||||
|
||||
interface LegacyProviderBaselineSeed {
|
||||
provider: CloudProvider;
|
||||
remoteVersion: number;
|
||||
|
||||
@@ -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}。",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { HistoryEntry } from "./commandHistoryStore";
|
||||
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
@@ -73,7 +74,9 @@ const {
|
||||
getCommandHistoryClearGeneration,
|
||||
hasCommandHistoryBeenCleared,
|
||||
markCommandHistoryCleared,
|
||||
fuzzyQueryHistory,
|
||||
queryHistory,
|
||||
queryRecentHistoryByCommand,
|
||||
recordCommand,
|
||||
subscribeCommandHistoryCleared,
|
||||
} = await import("./commandHistoryStore.ts");
|
||||
@@ -343,3 +346,155 @@ test("saving after a command and peer writes read the clear generation without p
|
||||
parse.mock.restore();
|
||||
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
|
||||
});
|
||||
|
||||
const QUERY_NOW = 1_700_000_000_000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
|
||||
return {
|
||||
command, hostId: "host-1", os: "linux", frequency: 1,
|
||||
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installHistoryEntries(entries: HistoryEntry[]): void {
|
||||
clearHistory();
|
||||
const raw = JSON.stringify({
|
||||
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||
}
|
||||
|
||||
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vi", { frequency: 100 }),
|
||||
historyEntry("vim duplicate"),
|
||||
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("Vim case"),
|
||||
historyEntry("vim tied"),
|
||||
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
|
||||
historyEntry("unrelated", { frequency: 100 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
|
||||
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
|
||||
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
|
||||
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
|
||||
assert.deepEqual(queryHistory("missing"), []);
|
||||
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
|
||||
});
|
||||
|
||||
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vim a", { frequency: 2 }),
|
||||
historyEntry("vim b", { frequency: 3 }),
|
||||
historyEntry("vim c", { frequency: 4 }),
|
||||
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
|
||||
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
|
||||
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||
});
|
||||
|
||||
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vi", { frequency: 100 }),
|
||||
historyEntry("vi alpha"),
|
||||
historyEntry("vi beta"),
|
||||
historyEntry("very indirect", { frequency: 2 }),
|
||||
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
|
||||
historyEntry("iv does not match"),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
|
||||
assert.deepEqual(fuzzyQueryHistory(""), []);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
|
||||
});
|
||||
|
||||
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
|
||||
const entries = [
|
||||
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("cd '/work/new folder'"),
|
||||
historyEntry("CD /work/tied"),
|
||||
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
|
||||
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
|
||||
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
|
||||
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
|
||||
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
|
||||
});
|
||||
|
||||
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("av-x"),
|
||||
historyEntry("avx"),
|
||||
historyEntry(`v${"a".repeat(1900)}x`),
|
||||
historyEntry("v-x"),
|
||||
historyEntry("v/x"),
|
||||
historyEntry("v_x"),
|
||||
historyEntry("v x"),
|
||||
historyEntry("vx adjacent"),
|
||||
historyEntry("v missing second character"),
|
||||
historyEntry("xv wrong order"),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(fuzzyQueryHistory("vx"), [
|
||||
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
|
||||
]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vv"), []);
|
||||
});
|
||||
|
||||
for (const size of [4_000, 40_000]) {
|
||||
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
|
||||
// Unsorted scores/timestamps, with the best match at the end of the store.
|
||||
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
|
||||
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
|
||||
}));
|
||||
installHistoryEntries(entries);
|
||||
queryHistory("missing"); // Warm the cache before measuring query operations.
|
||||
|
||||
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
|
||||
const pow = t.mock.method(Math, "pow");
|
||||
const lower = t.mock.method(String.prototype, "toLowerCase");
|
||||
// node:test cannot mock methods directly on arrays (including Array.prototype).
|
||||
const originalSort = Array.prototype.sort;
|
||||
const sortedSizes: number[] = [];
|
||||
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
|
||||
sortedSizes.push(this.length);
|
||||
return originalSort.call(this, compare);
|
||||
};
|
||||
t.after(() => { Array.prototype.sort = originalSort; });
|
||||
const queries = [
|
||||
() => queryHistory("vi", { limit: 5 }),
|
||||
() => fuzzyQueryHistory("vi", { limit: 5 }),
|
||||
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
const beforePow = pow.mock.callCount();
|
||||
const beforeLower = lower.mock.callCount();
|
||||
const result = query();
|
||||
assert.deepEqual(result, expected);
|
||||
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
|
||||
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
|
||||
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
|
||||
}
|
||||
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
|
||||
});
|
||||
}
|
||||
@@ -352,6 +352,36 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
||||
argumentPrefix?: string;
|
||||
}
|
||||
|
||||
interface RankedHistoryEntry {
|
||||
entry: HistoryEntry;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the best k distinct commands. Queries score each match once and
|
||||
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
|
||||
* for autocomplete). Entries arrive in store order for stable score ties.
|
||||
*/
|
||||
function retainHistoryMatch(
|
||||
best: RankedHistoryEntry[],
|
||||
entry: HistoryEntry,
|
||||
score: number,
|
||||
limit: number,
|
||||
): void {
|
||||
if (best.length >= limit && score <= best[best.length - 1].score) return;
|
||||
|
||||
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
|
||||
if (duplicate >= 0) {
|
||||
if (score <= best[duplicate].score) return;
|
||||
best.splice(duplicate, 1);
|
||||
}
|
||||
|
||||
// Insert after equal scores to preserve the original store order on ties.
|
||||
const index = best.findIndex((candidate) => score > candidate.score);
|
||||
if (best.length >= limit) best.pop();
|
||||
best.splice(index < 0 ? best.length : index, 0, { entry, score });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query history entries matching a prefix.
|
||||
* Returns entries sorted by relevance (frequency * recency).
|
||||
@@ -366,33 +396,17 @@ export function queryHistory(
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
for (const entry of store.entries) {
|
||||
if (hostId && entry.hostId !== hostId) continue;
|
||||
// Must match prefix
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
// Must not be identical to prefix
|
||||
if (entry.command === prefix) return false;
|
||||
|
||||
// Host filtering: strict per-host isolation
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by score (frequency * recency)
|
||||
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
|
||||
// Deduplicate by command text (keep highest scored)
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
if (entry.command === prefix) continue;
|
||||
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
return best.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,7 +424,7 @@ export function fuzzyQueryHistory(
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
// Host filtering
|
||||
@@ -420,24 +434,11 @@ export function fuzzyQueryHistory(
|
||||
|
||||
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
||||
if (matchScore > 0 && entry.command !== query) {
|
||||
scored.push({ entry, matchScore });
|
||||
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) =>
|
||||
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const { entry } of scored) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
return best.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,38 +463,26 @@ export function queryRecentHistoryByCommand(
|
||||
const commandPrefix = `${trimmedCommandName} `;
|
||||
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
for (const entry of store.entries) {
|
||||
if (hostId && entry.hostId !== hostId) continue;
|
||||
const lowerCommand = entry.command.toLowerCase();
|
||||
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
if (excludeCommand && entry.command === excludeCommand) return false;
|
||||
if (excludeCommand && entry.command === excludeCommand) continue;
|
||||
|
||||
if (normalizedArgumentPrefix) {
|
||||
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
||||
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
return best.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
function getCurrentCommandToken(command: string): string {
|
||||
@@ -569,28 +558,28 @@ function fuzzyScore(query: string, target: string): number {
|
||||
if (query.length > target.length) return 0;
|
||||
|
||||
let score = 0;
|
||||
let queryIdx = 0;
|
||||
let searchFrom = 0;
|
||||
let prevMatchIdx = -2;
|
||||
|
||||
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
||||
if (target[i] === query[queryIdx]) {
|
||||
queryIdx++;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
|
||||
// Skip unmatched runs in native string search, especially for long commands.
|
||||
const i = target.indexOf(query[queryIdx], searchFrom);
|
||||
if (i < 0) return 0;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
searchFrom = i + 1;
|
||||
}
|
||||
|
||||
// All query characters must be matched
|
||||
return queryIdx === query.length ? score : 0;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
resolvePreparedConvergentMigrationConflict,
|
||||
type PreparedConvergentMigration,
|
||||
} from '../application/convergentSyncMigration';
|
||||
import type { ShrinkFinding } from '../domain/syncGuards';
|
||||
@@ -358,6 +359,15 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
setConvergentBusy(true);
|
||||
setConvergentError(null);
|
||||
try {
|
||||
if (preparedConvergentMigration && !sync.convergentSyncConfig.initialized) {
|
||||
const prepared = resolvePreparedConvergentMigrationConflict(
|
||||
preparedConvergentMigration, addressKey, candidateDot,
|
||||
);
|
||||
setPreparedConvergentMigration(prepared);
|
||||
setConvergentPreview(prepared.plan.preview);
|
||||
toast.success(t('cloudSync.convergent.conflict.resolved'));
|
||||
return;
|
||||
}
|
||||
const { results } = await sync.resolveConvergentConflict(
|
||||
addressKey,
|
||||
candidateDot,
|
||||
@@ -371,7 +381,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} finally {
|
||||
setConvergentBusy(false);
|
||||
}
|
||||
}, [onApplyConvergentPayload, sync, t]);
|
||||
}, [onApplyConvergentPayload, preparedConvergentMigration, sync, t]);
|
||||
|
||||
const handleDowngradeConvergent = useCallback(async () => {
|
||||
if (!window.confirm(t('cloudSync.convergent.downgrade.confirm'))) return;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { AsidePanel } from './ui/aside-panel';
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
|
||||
|
||||
async function fixture(t: test.TestContext, layout: 'inline' | 'overlay' = 'inline') {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: env.window.localStorage });
|
||||
env.window.localStorage.setItem('desired-width', '720');
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const writes: number[] = [];
|
||||
const setItem = env.window.Storage.prototype.setItem;
|
||||
t.mock.method(env.window.Storage.prototype, 'setItem', function (this: Storage, key: string, value: string) {
|
||||
if (key === 'desired-width') writes.push(Number(value));
|
||||
setItem.call(this, key, value);
|
||||
});
|
||||
const render = (open = true) => renderer.render(<AsidePanel open={open} onClose={() => {}} title="Editor"
|
||||
layout={layout} resizable persistWidthStorageKey="desired-width" resizeAriaLabel="Resize details"
|
||||
dataSection="test-panel">Content</AsidePanel>);
|
||||
await render();
|
||||
const panel = env.document.querySelector<HTMLElement>('[data-section="test-panel"]')!;
|
||||
// Browser layout is covered by native-matrix.mjs. Here the actual rendered
|
||||
// border box is narrower than the remembered 720px preference.
|
||||
panel.getBoundingClientRect = () => new env.window.DOMRect(320, 36, 378, 494);
|
||||
const separator = panel.querySelector('[role="separator"]')!;
|
||||
const pointer = (target: EventTarget, type: string, x: number) => dispatchDomEvent(target,
|
||||
new env.window.MouseEvent(type, { bubbles: true, clientX: x }));
|
||||
t.after(async () => {
|
||||
await pointer(env.window, 'pointerup', 100);
|
||||
await renderer.unmount();
|
||||
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
env.cleanup();
|
||||
});
|
||||
return { writes, render, unmount: () => renderer.unmount(),
|
||||
start: () => pointer(separator, 'pointerdown', 100),
|
||||
move: (x: number) => pointer(env.window, 'pointermove', x),
|
||||
up: (x: number) => pointer(env.window, 'pointerup', x),
|
||||
cancel: (x: number) => pointer(env.window, 'pointercancel', x), env };
|
||||
}
|
||||
|
||||
test('dragging a fitted inline panel starts at its visible edge, then remembers the explicit size', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.start(); await f.move(120); await f.up(120);
|
||||
assert.deepEqual(f.writes, [358], '20px inward from visible 378px must remember 358px, without a jump from 720px');
|
||||
});
|
||||
|
||||
test('pressing a fitted resize handle without dragging preserves the larger desired width', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.deepEqual(f.writes, [], 'mount must not persist');
|
||||
await f.start(); await f.up(100);
|
||||
assert.deepEqual(f.writes, [], 'a click must not replace the remembered desired width');
|
||||
});
|
||||
|
||||
test('canceling a resize leaves the remembered width unchanged and restores pointer styles', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.start(); await f.move(120); await f.cancel(120); await f.up(120);
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.equal(f.env.document.body.style.cursor, '');
|
||||
assert.equal(f.env.document.body.style.userSelect, '');
|
||||
});
|
||||
|
||||
for (const mode of ['hidden', 'unmounted'] as const) {
|
||||
test(`${mode} panels do not persist a viewport clamp or a stale drag`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.start(); await f.move(120);
|
||||
if (mode === 'hidden') await f.render(false); else await f.unmount();
|
||||
await f.up(120);
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.equal(f.env.document.body.style.cursor, '');
|
||||
assert.equal(f.env.document.body.style.userSelect, '');
|
||||
});
|
||||
}
|
||||
|
||||
test('overlay resizing retains its existing desired-width behavior', async (t) => {
|
||||
const f = await fixture(t, 'overlay');
|
||||
await f.start(); await f.move(120); await f.up(120);
|
||||
assert.deepEqual(f.writes, [700]);
|
||||
});
|
||||
@@ -4,12 +4,64 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ConvergentSyncPanel } from './ConvergentSyncPanel.tsx';
|
||||
import type { ConvergentMigrationPreview } from '../../domain/sync.ts';
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number>) =>
|
||||
key === 'cloudSync.convergent.conflict.secretSet'
|
||||
? 'SECRET_SET'
|
||||
: `${key}${values?.count === undefined ? '' : `:${values.count}`}`;
|
||||
|
||||
test('uninitialized migration preview shows conflict choices and disables confirmation', () => {
|
||||
const preview: ConvergentMigrationPreview = {
|
||||
schemaVersion: 2,
|
||||
canInitialize: false,
|
||||
entityCounts: { hosts: 1 },
|
||||
settingsLeafCount: 0,
|
||||
conflictCount: 1,
|
||||
conflicts: [{
|
||||
address: { kind: 'entity-field', collection: 'hosts', entityId: 'host-1', field: 'label' },
|
||||
candidates: ['Left', 'Right'].map((value, index) => ({
|
||||
dot: { deviceId: value, counter: 1 },
|
||||
hlc: { wallTime: index + 1, logical: 0 },
|
||||
tombstone: false,
|
||||
value,
|
||||
selected: index === 0,
|
||||
})),
|
||||
}],
|
||||
shrinkFindings: [],
|
||||
providers: [],
|
||||
oldClientCompatibility: 'materialized-v1-snapshot',
|
||||
blockedReasons: ['The convergent state contains unresolved field conflicts'],
|
||||
};
|
||||
const renderPreview = (value: ConvergentMigrationPreview) => renderToStaticMarkup(
|
||||
<ConvergentSyncPanel
|
||||
t={t}
|
||||
resolvedLocale="en"
|
||||
config={{ enabled: false, initialized: false }}
|
||||
preview={value}
|
||||
busy={false}
|
||||
error={null}
|
||||
conflicts={[]}
|
||||
onToggle={() => {}}
|
||||
onConfirmMigration={() => {}}
|
||||
onCancelMigration={() => {}}
|
||||
onResolveConflict={() => {}}
|
||||
onDowngrade={() => {}}
|
||||
/>,
|
||||
);
|
||||
const markup = renderPreview(preview);
|
||||
assert.match(markup, /cloudSync\.convergent\.conflicts\.title:1/);
|
||||
assert.match(markup, /hosts\/host-1/);
|
||||
assert.equal((markup.match(/cloudSync\.convergent\.conflict\.choose/g) ?? []).length, 2);
|
||||
assert.match(markup, /<button[^>]*disabled=""[^>]*>cloudSync\.convergent\.preview\.confirm<\/button>/);
|
||||
|
||||
const resolvedMarkup = renderPreview({
|
||||
...preview, conflicts: [], conflictCount: 0, blockedReasons: [], canInitialize: true,
|
||||
});
|
||||
assert.doesNotMatch(resolvedMarkup, /cloudSync\.convergent\.conflict\.choose/);
|
||||
assert.doesNotMatch(resolvedMarkup, /<button[^>]*disabled=""[^>]*>cloudSync\.convergent\.preview\.confirm<\/button>/);
|
||||
});
|
||||
|
||||
test('secret conflict candidates never render their value', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConvergentSyncPanel
|
||||
|
||||
@@ -79,7 +79,9 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
onCancelMigration,
|
||||
onResolveConflict,
|
||||
onDowngrade,
|
||||
}) => (
|
||||
}) => {
|
||||
const conflictsToShow = config.initialized ? conflicts : (preview?.conflicts ?? []);
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
@@ -176,13 +178,13 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.initialized && conflicts.length > 0 && (
|
||||
{conflictsToShow.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t('cloudSync.convergent.conflicts.title', { count: conflicts.length })}
|
||||
{t('cloudSync.convergent.conflicts.title', { count: conflictsToShow.length })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{conflicts.map((conflict) => {
|
||||
{conflictsToShow.map((conflict) => {
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
return (
|
||||
<div key={addressKey} className="space-y-2 rounded-md bg-muted/40 p-3">
|
||||
@@ -230,4 +232,5 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -1479,7 +1479,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
className="h-7 w-7 shrink-0 rounded-md p-0 hover:bg-transparent"
|
||||
style={{ color: sidePanelTheme.mutedFg }}
|
||||
onClick={handleCloseSidePanel}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -370,21 +370,14 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
React.useEffect(() => () => resizeCleanupRef.current?.(), [open]);
|
||||
const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx;
|
||||
// Only containers opting into this constraint limit the rendered width.
|
||||
// Keep the animation's final minimum on the same cap as its width.
|
||||
const renderedWidth = resizable && layout === 'inline'
|
||||
? `min(${effectivePanelWidthPx}px, var(--aside-inline-max-width, ${effectivePanelWidthPx}px))`
|
||||
: `${effectivePanelWidthPx}px`;
|
||||
// Resizable panels always use pixel width so overlay and inline share the same drag path.
|
||||
const usesPixelWidth = resizable || layout === 'inline';
|
||||
|
||||
const panelStyle = usesPixelWidth
|
||||
? ({
|
||||
width: renderedWidth,
|
||||
['--aside-inline-width' as string]: renderedWidth,
|
||||
width: `${effectivePanelWidthPx}px`,
|
||||
['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
@@ -393,12 +386,9 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeCleanupRef.current?.();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = layout === 'inline'
|
||||
? event.currentTarget.parentElement?.getBoundingClientRect().width || panelWidthPx
|
||||
: panelWidthPx;
|
||||
const startWidth = panelWidthPx;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
|
||||
@@ -407,35 +397,26 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setPanelWidthPx(moveEvent.clientX === startX ? panelWidthPx
|
||||
: clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
|
||||
setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
|
||||
};
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
cleanup();
|
||||
// A handle click is not a request to forget a larger preference.
|
||||
if (upEvent.clientX === startX) return;
|
||||
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
|
||||
setPanelWidthPx(nextWidth);
|
||||
if (persistWidthStorageKey) {
|
||||
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
resizeCleanupRef.current = null;
|
||||
setPanelWidthPx(panelWidthPx);
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', cleanup);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
|
||||
resizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', cleanup);
|
||||
}, [layout, panelWidthPx, persistWidthStorageKey, resizable]);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
}, [panelWidthPx, persistWidthStorageKey, resizable]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../../domain/vaultGroupSelection";
|
||||
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
|
||||
import { VaultHostListSection } from "./VaultHostListSection";
|
||||
import { VAULT_HOST_GRID_MIN_CARD_WIDTH } from "./vaultHostGridLayout";
|
||||
import { LocalShellHostDetailsPanel } from "../LocalShellHostDetailsPanel";
|
||||
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
||||
import {
|
||||
@@ -861,13 +860,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
data-section="vault-stage"
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
|
||||
// Keep one host card and its existing px-4 padding beside an editor.
|
||||
// CSS resolves this against the current surface, including when the
|
||||
// retained vault is shown again; remembered widths remain untouched.
|
||||
style={{
|
||||
["--aside-inline-max-width" as string]: `max(0px, calc(100% - ${VAULT_HOST_GRID_MIN_CARD_WIDTH + 32}px))`,
|
||||
}}
|
||||
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
|
||||
data-section="vault-surface"
|
||||
>
|
||||
{/* Main Area */}
|
||||
|
||||
@@ -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.
|
||||
@@ -115,6 +115,42 @@ export interface ConvergentMigrationPlan {
|
||||
preview: ConvergentMigrationPreview;
|
||||
state: ConvergentSyncStateV2 | null;
|
||||
payload: SyncPayload | null;
|
||||
/** Retains sidecars and chats while field conflicts prevent payload creation. */
|
||||
materializedPayload: SyncPayload | null;
|
||||
}
|
||||
|
||||
const UNRESOLVED_FIELD_CONFLICTS = 'The convergent state contains unresolved field conflicts';
|
||||
|
||||
export function refreshConvergentSyncMigrationPlan(
|
||||
plan: ConvergentMigrationPlan,
|
||||
state: ConvergentSyncStateV2,
|
||||
): ConvergentMigrationPlan {
|
||||
if (!plan.state || !plan.materializedPayload) {
|
||||
throw new Error('No resolvable convergent migration state is available');
|
||||
}
|
||||
const materializedPayload = materializeSyncPayloadFromConvergentState(state, plan.materializedPayload);
|
||||
const conflicts = materializeConvergentSyncState(state).conflicts;
|
||||
const blockedReasons = plan.preview.blockedReasons.filter((reason) => reason !== UNRESOLVED_FIELD_CONFLICTS);
|
||||
const hasHardBlockers = blockedReasons.length > 0;
|
||||
if (conflicts.length > 0) blockedReasons.push(UNRESOLVED_FIELD_CONFLICTS);
|
||||
const canInitialize = blockedReasons.length === 0;
|
||||
return {
|
||||
preview: {
|
||||
...plan.preview,
|
||||
canInitialize,
|
||||
conflictCount: conflicts.length,
|
||||
conflicts,
|
||||
blockedReasons,
|
||||
entityCounts: Object.fromEntries(
|
||||
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
|
||||
.map((key) => [key, entityCount(materializedPayload, key)]),
|
||||
) as ConvergentMigrationPreview['entityCounts'],
|
||||
settingsLeafCount: countSettingsLeaves(materializedPayload.settings),
|
||||
},
|
||||
state: hasHardBlockers ? null : state,
|
||||
materializedPayload: hasHardBlockers ? null : materializedPayload,
|
||||
payload: canInitialize ? withConvergentSyncEnvelope(state, materializedPayload) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSchema(meta: SyncFileMeta): 1 | 2 | 'future' | 'invalid' {
|
||||
@@ -407,7 +443,8 @@ export function planConvergentSyncMigration(options: {
|
||||
}
|
||||
|
||||
const conflicts = state ? materializeConvergentSyncState(state).conflicts : [];
|
||||
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
|
||||
const hasHardBlockers = blockedReasons.length > 0;
|
||||
if (conflicts.length > 0) blockedReasons.push(UNRESOLVED_FIELD_CONFLICTS);
|
||||
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
|
||||
const payload = canInitialize && state
|
||||
? withConvergentSyncEnvelope(state, {
|
||||
@@ -437,7 +474,8 @@ export function planConvergentSyncMigration(options: {
|
||||
oldClientCompatibility: 'materialized-v1-snapshot',
|
||||
blockedReasons,
|
||||
},
|
||||
state: canInitialize ? state : null,
|
||||
state: hasHardBlockers ? null : state,
|
||||
materializedPayload: hasHardBlockers ? null : materialized,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
createConvergentSyncEnvelope,
|
||||
createConvergentSyncStateFromPayload,
|
||||
diffLegacySyncPayload,
|
||||
dotKey,
|
||||
hydrateConvergentSyncEnvelope,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
mergeConvergentSyncStates,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
resolveConvergentFieldConflict,
|
||||
serializeConvergentSyncState,
|
||||
validateConvergentSyncPayload,
|
||||
withConvergentSyncEnvelope,
|
||||
@@ -660,6 +663,86 @@ test('migration blocks unresolved v1 conflicts and future provider schemas', ()
|
||||
assert.equal(future.preview.providers[0].schemaVersion, 'future');
|
||||
});
|
||||
|
||||
test('migration retains conflict-only state and refreshes after each local resolution', () => {
|
||||
const baseline = payload();
|
||||
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||
const leftPayload = payload('Left');
|
||||
leftPayload.hosts[0].hostname = 'left.example.com';
|
||||
const rightPayload = payload('Right');
|
||||
rightPayload.hosts[0].hostname = 'right.example.com';
|
||||
const state = mergeConvergentSyncStates(
|
||||
applyLegacySyncPayload(base, baseline, leftPayload, 'left', NOW + 1),
|
||||
applyLegacySyncPayload(base, baseline, rightPayload, 'right', NOW + 2),
|
||||
);
|
||||
const remote = withConvergentSyncEnvelope(state, {
|
||||
syncedAt: NOW + 3,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
aiSessions: [{
|
||||
id: 'chat', title: 'Chat', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: NOW, updatedAt: NOW, messages: [],
|
||||
}],
|
||||
});
|
||||
const options = {
|
||||
localPayload: emptyPayload(),
|
||||
localTrustedBaseline: null,
|
||||
providers: [{
|
||||
provider: 'github' as const,
|
||||
status: 'ready' as const,
|
||||
meta: meta({ syncSchemaVersion: 2 }),
|
||||
payload: remote,
|
||||
trustedBaseline: null,
|
||||
}],
|
||||
deviceId: 'local-device',
|
||||
now: NOW + 4,
|
||||
};
|
||||
let plan = planConvergentSyncMigration(options);
|
||||
const originalPlan = plan;
|
||||
assert.equal(plan.preview.canInitialize, false);
|
||||
assert.equal(plan.preview.conflictCount, 2);
|
||||
assert.deepEqual(plan.preview.blockedReasons, ['The convergent state contains unresolved field conflicts']);
|
||||
assert.ok(plan.state);
|
||||
assert.equal(plan.payload, null);
|
||||
|
||||
for (const remaining of [1, 0]) {
|
||||
const conflict = plan.preview.conflicts[0];
|
||||
const candidate = conflict.candidates.find((entry) => entry.dot.deviceId === 'left')!;
|
||||
const resolved = resolveConvergentFieldConflict(
|
||||
plan.state!, conflict, dotKey(candidate.dot), 'local-device', NOW + 5,
|
||||
);
|
||||
plan = refreshConvergentSyncMigrationPlan(plan, resolved);
|
||||
assert.equal(plan.preview.conflictCount, remaining);
|
||||
assert.equal(plan.preview.conflicts.length, remaining);
|
||||
assert.equal(plan.preview.canInitialize, remaining === 0);
|
||||
assert.equal(plan.preview.blockedReasons.length, remaining === 0 ? 0 : 1);
|
||||
assert.equal(plan.payload === null, remaining > 0);
|
||||
}
|
||||
assert.equal(originalPlan.preview.conflictCount, 2);
|
||||
assert.equal(plan.payload?.hosts[0].label, 'Left');
|
||||
assert.equal(plan.payload?.hosts[0].hostname, 'left.example.com');
|
||||
assert.equal(plan.payload?.convergentSync?.schemaVersion, 2);
|
||||
assert.deepEqual(plan.payload?.pluginSidecars, remote.pluginSidecars);
|
||||
assert.deepEqual(plan.payload?.aiSessions, remote.aiSessions);
|
||||
assert.deepEqual(plan.preview.providers, originalPlan.preview.providers);
|
||||
assert.deepEqual(plan.preview.entityCounts, originalPlan.preview.entityCounts);
|
||||
|
||||
// A field picker must never make a separate migration blocker resolvable.
|
||||
const blocked = planConvergentSyncMigration({ ...options, localPayload: payload('Unbased local edit') });
|
||||
assert.ok(blocked.preview.conflictCount > 0);
|
||||
assert.match(blocked.preview.blockedReasons.join('; '), /no trusted legacy baseline/);
|
||||
assert.equal(blocked.state, null);
|
||||
assert.equal(blocked.materializedPayload, null);
|
||||
assert.equal(blocked.payload, null);
|
||||
assert.throws(() => refreshConvergentSyncMigrationPlan(blocked, state), /No resolvable/);
|
||||
|
||||
const unavailable = planConvergentSyncMigration({
|
||||
...options,
|
||||
providers: [...options.providers, { provider: 'google', status: 'unavailable', message: 'offline' }],
|
||||
});
|
||||
assert.equal(unavailable.preview.canInitialize, false);
|
||||
assert.equal(unavailable.state, null);
|
||||
assert.equal(unavailable.payload, null);
|
||||
});
|
||||
|
||||
test('joining existing v2 data blocks changed legacy writers without a trusted baseline', () => {
|
||||
const remoteState = createConvergentSyncStateFromPayload(payload('Remote'), 'remote', NOW);
|
||||
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
|
||||
@@ -679,6 +762,7 @@ test('joining existing v2 data blocks changed legacy writers without a trusted b
|
||||
|
||||
assert.equal(plan.preview.canInitialize, false);
|
||||
assert.match(plan.preview.blockedReasons.join(' '), /no trusted legacy baseline/);
|
||||
assert.equal(plan.state, null);
|
||||
});
|
||||
|
||||
test('joining existing v2 data blocks a shrunk legacy local source', () => {
|
||||
@@ -710,6 +794,7 @@ test('joining existing v2 data blocks a shrunk legacy local source', () => {
|
||||
|
||||
assert.equal(plan.preview.canInitialize, false);
|
||||
assert.match(plan.preview.blockedReasons.join(' '), /legacy-local:local-device.*remove too many entities/);
|
||||
assert.equal(plan.state, null);
|
||||
});
|
||||
|
||||
test('joining existing v2 data blocks a shrunk legacy provider source', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -393,3 +393,46 @@ test('real pending PowerShell input is still not sent', async (t) => {
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('OSC 133 A marker readies exec for a colored P10k prompt the whitelist rejects', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
// Colored P10k-style prompt after a prompt-start marker; the SGR codes and
|
||||
// ❯ shape are irrelevant — the marker proves the shell accepts input.
|
||||
trackSessionIdlePrompt(
|
||||
session,
|
||||
'\x1b]133;A\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ',
|
||||
);
|
||||
assert.equal(readyPrompt(session), '~/src ❯ ');
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('OSC 133 C/D markers keep exec gated even when output echoes a known prompt', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, '\x1b]133;A\x07PS C:\\Users\\alice> ');
|
||||
assert.equal(readyPrompt(session), 'PS C:\\Users\\alice> ');
|
||||
// Command in flight; the program echoes prompt-shaped text. Text says
|
||||
// ready, the last marker says otherwise — markers override.
|
||||
trackSessionIdlePrompt(session, '\x1b]133;C\x07build running...\r\nPS C:\\Users\\alice> ');
|
||||
assert.equal(readyPrompt(session), '');
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { trackSessionIdlePrompt, getFreshIdlePrompt, getVisibleIdlePrompt, computeOsc133ReadyState } = require('./shellUtils.cjs');
|
||||
const { startPtyJob, resolveEffectiveShellKind } = require('./ptyExec.cjs');
|
||||
|
||||
const A = '\x1b]133;A\x07';
|
||||
const B = '\x1b]133;B\x07';
|
||||
const C = '\x1b]133;C\x07';
|
||||
const D = '\x1b]133;D;0\x1b\\';
|
||||
const ready = session => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
|
||||
|
||||
class TestPty extends EventEmitter {
|
||||
writes = [];
|
||||
write(data) {
|
||||
this.writes.push(data);
|
||||
const marker = String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1];
|
||||
if (marker) queueMicrotask(() => this.emit('data', `${marker}_S\nOK\n${marker}_E:0\n`));
|
||||
}
|
||||
}
|
||||
|
||||
test('OSC prompts reject pending input and continuations without writing any bytes', async () => {
|
||||
for (const prompt of ['review@local:~$ ', 'custom λ ', '> ']) {
|
||||
for (const pending of ['echo half-typed', ' ', 'echo "unfinished\r\n> ']) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + prompt + B);
|
||||
assert.equal(ready(session), prompt);
|
||||
trackSessionIdlePrompt(session, pending);
|
||||
assert.equal(ready(session), '', JSON.stringify({ prompt, pending }));
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC prompt start alone does not revive a stale cached prompt', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, 'PS C:\\Users\\review> ');
|
||||
trackSessionIdlePrompt(session, A);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '❯ ' + B);
|
||||
assert.equal(getFreshIdlePrompt(session), '');
|
||||
assert.equal(ready(session), '❯ ');
|
||||
assert.equal(resolveEffectiveShellKind('', ready(session), { loginShellHint: 'posix' }), 'posix');
|
||||
});
|
||||
|
||||
test('OSC custom prompts need prompt-end B before arbitrary text can qualify', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ');
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
});
|
||||
|
||||
test('OSC busy and finished states survive 4KB tail eviction until a new prompt', () => {
|
||||
for (const marker of [C, D]) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'review@local:~$ ' + B + marker);
|
||||
trackSessionIdlePrompt(session, 'x'.repeat(4200) + '\r\nreview@local:~$ ');
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC busy marker is retained even when it arrives inside one oversized chunk', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, C + 'x'.repeat(4200) + '\r\nreview@local:~$ ');
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
});
|
||||
|
||||
test('OSC parser accepts complete BEL/ST parameterized markers but rejects prefixes and invalid opcodes', () => {
|
||||
for (const tail of ['\x1b]133;A', '\x1b]133;Aardvark\x07', '\x1b]133;C-more\x07']) {
|
||||
assert.equal(computeOsc133ReadyState(tail), null, JSON.stringify(tail));
|
||||
}
|
||||
assert.equal(computeOsc133ReadyState('\x1b]133;A;cl=m\x1b\\'), true);
|
||||
assert.equal(computeOsc133ReadyState(C + '\x1b]133;A'), false);
|
||||
});
|
||||
|
||||
test('OSC secondary prompt markers never make unfinished input ready', () => {
|
||||
for (const prompt of ['> ', 'review@local:~$ ']) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ' + B + 'echo "\r\n');
|
||||
trackSessionIdlePrompt(session, '\x1b]133;A;k=s\x07' + prompt + B);
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
assert.equal(computeOsc133ReadyState('\x1b]133;A;k=s\x07'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC markers and custom prompts survive every one-byte PTY split', () => {
|
||||
const session = {};
|
||||
for (const byte of '\x1b]133;A;cl=m\x1b\\custom λ \x1b]133;B\x1b\\') {
|
||||
trackSessionIdlePrompt(session, byte);
|
||||
}
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
for (const byte of C) trackSessionIdlePrompt(session, byte);
|
||||
assert.equal(ready(session), '');
|
||||
});
|
||||
|
||||
test('OSC prompt recovery after erased input accepts only the original empty prompt', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ' + B + 'partial');
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '\r\x1b[2Kcustom λ ');
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
trackSessionIdlePrompt(session, 'echo "\r\n> ');
|
||||
trackSessionIdlePrompt(session, '\r\x1b[2Kcustom λ ');
|
||||
assert.equal(ready(session), '', 'continuation requires a new prompt cycle');
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), '', 'newline and A/B do not prove cancellation');
|
||||
trackSessionIdlePrompt(session, C + D + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
});
|
||||
|
||||
test('ambiguous backspace-space-backspace output never authorizes command injection', async () => {
|
||||
for (const prompt of ['review@local:~$ ', 'custom \u03bb ']) {
|
||||
for (const erased of ['x\b \b', 'xy\b \b\b \b',
|
||||
'hello' + '\b'.repeat(5) + ' '.repeat(5) + '\b'.repeat(5)]) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + prompt + B);
|
||||
trackSessionIdlePrompt(session, erased);
|
||||
assert.equal(ready(session), '', JSON.stringify({ prompt, erased }));
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
trackSessionIdlePrompt(session, ' ');
|
||||
assert.equal(ready(session), '', 'a new space advances the input cursor');
|
||||
trackSessionIdlePrompt(session, '\b');
|
||||
assert.equal(ready(session), '', 'moving left must not erase a genuinely pending space');
|
||||
trackSessionIdlePrompt(session, ' \b');
|
||||
assert.equal(ready(session), '', 'moving right and left across a blank must remain pending');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Bash line editing keeps ambiguous deletion and pending blanks gated', (t) => {
|
||||
if (process.platform === 'win32') return t.skip('requires a POSIX Bash PTY');
|
||||
const probe = spawnSync('python3', ['-c', 'import pty']);
|
||||
if (probe.error?.code === 'ENOENT') return t.skip('python3 is unavailable');
|
||||
assert.equal(probe.status, 0, probe.stderr?.toString());
|
||||
for (const args of [[], ['--wide-prompt']]) {
|
||||
const result = spawnSync('python3', [path.resolve(__dirname, '../../../scripts/fixtures/osc133-erase-input.py'), ...args],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
if (result.status === 77) return t.skip('a UTF-8 locale is unavailable');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const scenarios = JSON.parse(result.stdout);
|
||||
assert.equal(scenarios.length, 27);
|
||||
const word = scenarios.find(({ name }) => name === 'Ctrl-W whole word');
|
||||
assert.ok(word.states.at(-1).raw.includes('\b'.repeat(5) + ' '.repeat(5) + '\b'.repeat(5)));
|
||||
const forward = scenarios.find(({ name }) => name === 'mixed forward delete');
|
||||
const kill = scenarios.find(({ name }) => name === 'indistinguishable mixed line kill');
|
||||
// The first leaves a genuine blank; the second empties input. Identical
|
||||
// output cannot certify which happened, so both must stay conservatively gated.
|
||||
assert.equal(forward.states.slice(1).map(({ raw }) => raw).join(''),
|
||||
kill.states.slice(1).map(({ raw }) => raw).join(''));
|
||||
for (const scenario of scenarios) {
|
||||
const session = {}, splitSession = {};
|
||||
for (const { step, raw, ready: expected } of scenario.states) {
|
||||
trackSessionIdlePrompt(session, raw);
|
||||
for (const ch of raw) {
|
||||
trackSessionIdlePrompt(splitSession, ch);
|
||||
if (!expected) assert.equal(Boolean(ready(splitSession)), false,
|
||||
`intermediate byte of ${scenario.name}: ${step}`);
|
||||
}
|
||||
assert.equal(Boolean(ready(session)), expected, `${scenario.name}: ${step}`);
|
||||
assert.equal(Boolean(ready(splitSession)), expected, `byte-split ${scenario.name}: ${step}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Bash vi replacement and multiline Ctrl-L remain unready through every packet boundary', async (t) => {
|
||||
if (process.platform === 'win32') return t.skip('requires a POSIX Bash PTY');
|
||||
const probe = spawnSync('python3', ['-c', 'import pty']);
|
||||
if (probe.error?.code === 'ENOENT') return t.skip('python3 is unavailable');
|
||||
assert.equal(probe.status, 0, probe.stderr?.toString());
|
||||
const result = spawnSync('python3', [path.resolve(__dirname, '../../../scripts/fixtures/osc133-erase-input.py'), '--risk-probes'],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
if (result.status === 77) return t.skip('a UTF-8 locale is unavailable');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const scenarios = JSON.parse(result.stdout);
|
||||
assert.equal(scenarios.length, 2);
|
||||
assert.equal(scenarios[0].readline_line, ' ', 'bind -x confirms vi replacement leaves real input');
|
||||
assert.equal(scenarios[0].states.slice(1).map(({ raw }) => raw).join(''), 'x\b \b');
|
||||
assert.equal(scenarios[1].readline_line, 'echo pending');
|
||||
const redraw = scenarios[1].states.at(-1).raw;
|
||||
assert.ok(redraw.includes(A + 'header\r\r\n'));
|
||||
assert.ok(redraw.includes(B), 'Ctrl-L redraw includes the input boundary before re-echo');
|
||||
for (const scenario of scenarios) {
|
||||
const session = {};
|
||||
for (const { step, raw, ready: expected } of scenario.states) {
|
||||
for (const ch of raw) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
if (!expected) assert.equal(ready(session), '', `${scenario.name}: ${step}, intermediate packet`);
|
||||
}
|
||||
assert.equal(Boolean(ready(session)), expected, `${scenario.name}: ${step}`);
|
||||
}
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
// Repeated redraws, their own newlines, and secondary prompts cannot reset
|
||||
// uncertain input. Only a completed command lifecycle permits recovery.
|
||||
for (const text of ['\r\n' + A + 'header\r\nreview@local:~$ ' + B,
|
||||
'\x1b]133;A;k=s\x07> ' + B + A + 'custom ' + B]) {
|
||||
for (const ch of text) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
}
|
||||
trackSessionIdlePrompt(session, C + D + A + 'custom ' + B);
|
||||
assert.equal(ready(session), 'custom ');
|
||||
}
|
||||
});
|
||||
|
||||
test('same-row OSC redraw stays gated until a fresh prompt lifecycle', () => {
|
||||
const session = {}, prompt = 'review@local:~$ ';
|
||||
trackSessionIdlePrompt(session, A + prompt + B + 'x ');
|
||||
for (const ch of '\r' + A + prompt + B) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + prompt + B);
|
||||
assert.equal(ready(session), '', 'a newline cannot clear the redraw latch');
|
||||
trackSessionIdlePrompt(session, 'x' + '\r' + A + prompt + B);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, C + D + A + prompt + B);
|
||||
assert.equal(ready(session), prompt);
|
||||
});
|
||||
|
||||
test('OSC repeats complete execution and prompt cycles without stale prompt state', async () => {
|
||||
const session = {};
|
||||
for (const prompt of ['custom λ ', 'other ❯ ', 'review@local:~$ ']) {
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + prompt + B);
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
trackSessionIdlePrompt(session, C + 'output' + D);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
});
|
||||
@@ -224,11 +224,109 @@ function looksLikeIdleAutoLogout(outputTail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── OSC 133 (shell-integration) readiness ──
|
||||
//
|
||||
// A starts a prompt; B ends its rendering and starts command input; C starts
|
||||
// execution/output; D finishes execution. A alone does not prove empty input.
|
||||
// Keep lifecycle state beyond rolling-tail eviction, and accept custom prompts
|
||||
// only while the B-marked cursor line is unchanged and no continuation arrived.
|
||||
// A-only integrations retain the existing whitelist checks.
|
||||
const OSC133_MARKER_PATTERN = /\u001B\]133;([ABCD])(?:;([^\u0007\u001B]*))?(?:\u0007|\u001B\\)/g;
|
||||
|
||||
function isOsc133PrimaryPrompt(marker) {
|
||||
// Kitty's k=s marks PS2, which is unfinished command input, not a fresh PS1.
|
||||
return marker[1] === "A" && !marker[2]?.split(";").includes("k=s");
|
||||
}
|
||||
|
||||
function computeOsc133ReadyState(tail) {
|
||||
if (typeof tail !== "string" || !tail) return null;
|
||||
let ready = null;
|
||||
for (const marker of tail.matchAll(OSC133_MARKER_PATTERN)) {
|
||||
if (marker[1] !== "B") ready = isOsc133PrimaryPrompt(marker);
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
function promptTrackingView(session) {
|
||||
const tail = session?._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return { tail: "", alternateScreenActive: false };
|
||||
if (session.osc133Ready === true) {
|
||||
const start = session._osc133PromptStart;
|
||||
return {
|
||||
// Never revive text from before A, or a prompt whose start was evicted.
|
||||
tail: typeof start === "number" && start >= 0 ? tail.slice(start) : "",
|
||||
alternateScreenActive: session._osc133PromptAltScreenAtStart === true,
|
||||
};
|
||||
}
|
||||
return { tail, alternateScreenActive: session._promptTrackAltScreenAtTailStart === true };
|
||||
}
|
||||
|
||||
function matchesOsc133EmptyPrompt(session, view) {
|
||||
if (session._osc133PromptRedrawPending) return false;
|
||||
if (session._osc133PromptLine == null) return true;
|
||||
if (session._osc133PromptContinued) return false;
|
||||
const { terminalCursorState } = require("../terminalLogSanitizer.cjs");
|
||||
const cursor = terminalCursorState(view.tail, view);
|
||||
// Readline deletion and vi replacement can emit identical BS-space-BS
|
||||
// bytes. Preserve every displayed blank; output cannot certify its removal.
|
||||
return cursor.column === session._osc133PromptColumn
|
||||
&& cursor.line === session._osc133PromptLine;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const previousTail = session._promptTrackTail || "";
|
||||
const combined = `${previousTail}${chunk}`;
|
||||
session.osc133Ready ??= null;
|
||||
for (const marker of combined.matchAll(OSC133_MARKER_PATTERN)) {
|
||||
const end = marker.index + marker[0].length;
|
||||
// Replaying old markers would erase pending-input state on every chunk.
|
||||
// A marker split across chunks is consumed when its terminator arrives.
|
||||
if (end <= previousTail.length) continue;
|
||||
if (isOsc133PrimaryPrompt(marker)) {
|
||||
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
|
||||
const beforeMarker = combined.slice(0, marker.index);
|
||||
const priorSession = { ...session, _promptTrackTail: beforeMarker };
|
||||
// A/B also occur during redraw, including multiline PS1 and packets
|
||||
// ending before pending input is re-echoed. Neither A nor a newline
|
||||
// proves cancellation. Keep uncertain input gated until C/D establishes
|
||||
// a new command lifecycle; a redraw cannot clear this latch.
|
||||
session._osc133PromptRedrawPending ||= session.osc133Ready === true
|
||||
&& session._osc133PromptLine != null
|
||||
&& !matchesOsc133EmptyPrompt(priorSession, promptTrackingView(priorSession));
|
||||
session.osc133Ready = true;
|
||||
session._osc133PromptStart = end;
|
||||
session._osc133PromptAltScreenAtStart = alternateScreenActiveAfter(
|
||||
combined.slice(0, end), session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
session._osc133PromptLine = null;
|
||||
session._osc133PromptEnd = -1;
|
||||
session._osc133PromptContinued = false;
|
||||
} else if (marker[1] === "B") {
|
||||
if (session.osc133Ready === true && session._osc133PromptStart >= 0) {
|
||||
const { terminalCursorState } = require("../terminalLogSanitizer.cjs");
|
||||
const cursor = terminalCursorState(
|
||||
combined.slice(session._osc133PromptStart, end),
|
||||
{ alternateScreenActive: session._osc133PromptAltScreenAtStart === true },
|
||||
);
|
||||
session._osc133PromptLine = cursor.line;
|
||||
session._osc133PromptColumn = cursor.column;
|
||||
session._osc133PromptEnd = end;
|
||||
}
|
||||
} else {
|
||||
// Secondary prompts continue input; only C/D establish a new lifecycle.
|
||||
session._osc133PromptRedrawPending = marker[1] === "A";
|
||||
session.osc133Ready = false;
|
||||
session._osc133PromptLine = null;
|
||||
session._osc133PromptStart = -1;
|
||||
session._osc133PromptEnd = -1;
|
||||
}
|
||||
}
|
||||
if (session._osc133PromptLine != null) {
|
||||
const inputStart = Math.max(previousTail.length, session._osc133PromptEnd);
|
||||
if (combined.slice(inputStart).includes("\n")) session._osc133PromptContinued = true;
|
||||
}
|
||||
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
|
||||
if (overflow > 0) {
|
||||
// The kept tail no longer starts where the previous tail started. Replay
|
||||
@@ -239,11 +337,14 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
combined.slice(0, overflow),
|
||||
session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
for (const key of ["_osc133PromptStart", "_osc133PromptEnd"]) {
|
||||
if (typeof session[key] === "number") session[key] = Math.max(-1, session[key] - overflow);
|
||||
}
|
||||
}
|
||||
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
const prompt = extractTrailingIdlePrompt(promptTrackingView(session).tail);
|
||||
if (prompt) {
|
||||
session.lastIdlePrompt = prompt;
|
||||
session.lastIdlePromptAt = Date.now();
|
||||
@@ -269,11 +370,18 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// back to `shellKind` alone.
|
||||
function getFreshIdlePrompt(session) {
|
||||
if (!session) return "";
|
||||
|
||||
// C/D gate text-shaped output. A never bypasses cache freshness or input
|
||||
// checks: an earlier prompt remains marked A while a user edits a command.
|
||||
if (session.osc133Ready === false) return "";
|
||||
|
||||
const cached = session.lastIdlePrompt;
|
||||
if (!cached) return "";
|
||||
|
||||
const tail = session._promptTrackTail;
|
||||
const view = promptTrackingView(session);
|
||||
const tail = view.tail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
if (!matchesOsc133EmptyPrompt(session, view)) return "";
|
||||
|
||||
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
|
||||
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
|
||||
@@ -289,15 +397,23 @@ function getFreshIdlePrompt(session) {
|
||||
// screen is still an idle default prompt (#3927). Collapse those cursor and
|
||||
// erase operations, then accept only the line the cursor is actually on.
|
||||
function getVisibleIdlePrompt(session) {
|
||||
const tail = session?._promptTrackTail;
|
||||
const view = promptTrackingView(session);
|
||||
const tail = view.tail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
|
||||
// Mirror getFreshIdlePrompt: C/D gate output, B confirms an empty input line.
|
||||
if (session.osc133Ready === false) return "";
|
||||
if (!matchesOsc133EmptyPrompt(session, view)) return "";
|
||||
|
||||
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
|
||||
const cursorLine = terminalCursorLine(tail, {
|
||||
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
|
||||
});
|
||||
const cursorLine = terminalCursorLine(tail, view);
|
||||
const rightTrimmed = cursorLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
if (session.osc133Ready === true && session._osc133PromptLine != null) {
|
||||
// A complete B gives the custom prompt boundary; its unchanged cursor line
|
||||
// and lack of continuation are checked above before bypassing shape checks.
|
||||
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
|
||||
}
|
||||
if (
|
||||
!isDefaultPowerShellPromptLine(rightTrimmed)
|
||||
&& !isDefaultPosixPromptLine(rightTrimmed)
|
||||
@@ -1251,6 +1367,7 @@ module.exports = {
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
computeOsc133ReadyState,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
readWindowsRegistryPath,
|
||||
createWindowsLivePathResolver,
|
||||
trackSessionIdlePrompt,
|
||||
computeOsc133ReadyState,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
@@ -1378,3 +1379,98 @@ test("a failed machine hive does not promote user directories ahead of inherited
|
||||
platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [],
|
||||
}), "C:\\Windows\\System32;C:\\user-tools");
|
||||
});
|
||||
|
||||
// ── OSC 133 shell-integration readiness ──
|
||||
|
||||
test("computeOsc133ReadyState derives readiness from the last marker in the raw tail", () => {
|
||||
assert.equal(computeOsc133ReadyState(""), null);
|
||||
assert.equal(computeOsc133ReadyState("plain output with no markers"), null);
|
||||
// BEL terminator, A last → ready.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07user@host ~ % "), true);
|
||||
// ST terminator on D; full cycle A→B→C→D ends not-at-prompt.
|
||||
assert.equal(
|
||||
computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;B\x07ls\x07\x1b]133;C\x07files\x1b]133;D\x1b\\"),
|
||||
false,
|
||||
);
|
||||
// Parameterized D form (e.g. `133;D;0`) still matches the marker prefix.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;D;0\x07"), false);
|
||||
// Output start (C) after the last prompt start means a command is running.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;C\x07build output..."), false);
|
||||
// A fresh prompt start after a finished command is ready again.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;D\x07\x1b]133;A\x07~ ❯ "), true);
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt stores OSC 133 state alongside the whitelist prompt", () => {
|
||||
// No markers → null (non-integrated shell, whitelist fallback unchanged).
|
||||
const plain = {};
|
||||
trackSessionIdlePrompt(plain, "Last login...\r\nuser@host ~ % ");
|
||||
assert.equal(plain.osc133Ready, null);
|
||||
assert.equal(getFreshIdlePrompt(plain), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 keeps whitelisted prompts and readies custom prompts after B", () => {
|
||||
// Colored Powerlevel10k-style prompt: theme SGR codes + ❯, after an A marker.
|
||||
const p10k = {};
|
||||
trackSessionIdlePrompt(
|
||||
p10k,
|
||||
"\x1b]133;A\x07\x1b]0;title\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ",
|
||||
);
|
||||
assert.equal(p10k.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(p10k), "~/src ❯ ");
|
||||
assert.equal(getVisibleIdlePrompt(p10k), "~/src ❯ ");
|
||||
|
||||
// B marks the custom prompt boundary; A alone cannot prove empty input.
|
||||
const custom = {};
|
||||
trackSessionIdlePrompt(custom, "\x1b]133;A\x07myprompt λ ");
|
||||
assert.equal(custom.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(custom), "");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "");
|
||||
trackSessionIdlePrompt(custom, "\x1b]133;B\x07");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "myprompt λ ");
|
||||
});
|
||||
|
||||
test("OSC 133 C/D markers override whitelisted-looking prompt text (not ready)", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07PS C:\\Users\\alice> ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice> ");
|
||||
|
||||
// Command in flight; a running program then echoes prompt-shaped text —
|
||||
// the exact case where the whitelist lies (#4048 false-ready).
|
||||
trackSessionIdlePrompt(session, "\x1b]133;B\x07dir\x07\x1b]133;C\x07running...\r\nPS C:\\Users\\alice> ");
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
|
||||
// D (finished) without a new A marker stays not-ready even with
|
||||
// prompt-shaped text after it.
|
||||
trackSessionIdlePrompt(session, "done\x1b]133;D;0\x1b\\\r\nuser@host ~ % ");
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
|
||||
// A new prompt start restores readiness.
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07user@host ~ % ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 state survives markers split across PTY chunks via the rolling tail", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "welcome\r\n\x1b]133;");
|
||||
assert.equal(session.osc133Ready, null);
|
||||
trackSessionIdlePrompt(session, "A\x07user@host ~ % ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getVisibleIdlePrompt(session), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 lifecycle state survives rolling-tail eviction without reviving old prompts", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
// Retain lifecycle state, but fail closed when the prompt itself was evicted.
|
||||
trackSessionIdlePrompt(session, "x".repeat(4200));
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
});
|
||||
@@ -11,7 +11,22 @@
|
||||
* logs keep shell history instead of tilde rows and status lines.
|
||||
*/
|
||||
|
||||
const { fullwidthRanges, wideRanges } = require("./terminalWidthData.cjs");
|
||||
const ZERO_WIDTH_MARK_RE = /[\p{Mn}\p{Me}]/u;
|
||||
const ZERO_WIDTH_FORMAT_RE = /(?=\p{Cf})\p{Default_Ignorable_Code_Point}/u;
|
||||
const CSI_FINAL_RE = /[@-~]/;
|
||||
|
||||
function inWidthRanges(codePoint, ranges) {
|
||||
if (codePoint < ranges[0] || codePoint > ranges.at(-1)) return false;
|
||||
let low = 0, high = ranges.length / 2 - 1;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2), index = middle * 2;
|
||||
if (codePoint < ranges[index]) high = middle - 1;
|
||||
else if (codePoint > ranges[index + 1]) low = middle + 1;
|
||||
else return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// ANSI palette colors are emitted as CSS variables (with hex fallbacks) so the
|
||||
// wrapping HTML page can provide light and dark palettes. Indexes 0-7 are the
|
||||
// basic colors, 8-15 the bright colors.
|
||||
@@ -64,9 +79,7 @@ class TerminalTextRenderer {
|
||||
if (!input) return;
|
||||
this.timestamp = timestamp;
|
||||
|
||||
for (const ch of input) {
|
||||
this.#consume(ch);
|
||||
}
|
||||
for (const ch of input) this.#consume(ch);
|
||||
}
|
||||
|
||||
finish() {
|
||||
@@ -77,14 +90,13 @@ class TerminalTextRenderer {
|
||||
}
|
||||
|
||||
getCursorLine() {
|
||||
const line = this.lines[this.row] || [];
|
||||
return line.map((cell) => cell?.ch || " ").join("");
|
||||
return (this.lines[this.row] || []).map(cellText).join("");
|
||||
}
|
||||
|
||||
toString({ includePendingClearedScreen = false, formatLine = (line) => line } = {}) {
|
||||
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
|
||||
return lines
|
||||
.map((line) => formatLine(line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""), line.timestamp))
|
||||
.map((line) => formatLine(line.map(cellText).join("").replace(/[ \t]+$/g, ""), line.timestamp))
|
||||
.join("\n")
|
||||
.replace(/\n+$/g, "");
|
||||
}
|
||||
@@ -359,13 +371,46 @@ class TerminalTextRenderer {
|
||||
if (this.timestamp !== undefined) line.timestamp = this.timestamp;
|
||||
while (line.length < this.col) line.push(createCell(" ", createDefaultStyle()));
|
||||
for (const ch of text) {
|
||||
line[this.col] = createCell(ch, this.style);
|
||||
this.col += 1;
|
||||
const codePoint = ch.codePointAt(0);
|
||||
// Readline counts code points, including spacing marks and emoji
|
||||
// modifiers, rather than collapsing an entire grapheme into one cell.
|
||||
const zeroWidth = ZERO_WIDTH_MARK_RE.test(ch)
|
||||
|| (codePoint !== 0x00ad && ZERO_WIDTH_FORMAT_RE.test(ch))
|
||||
|| (codePoint >= 0x1160 && codePoint <= 0x11ff)
|
||||
|| (codePoint >= 0xd7b0 && codePoint <= 0xd7ff);
|
||||
const width = zeroWidth ? 0
|
||||
: inWidthRanges(codePoint, fullwidthRanges) || inWidthRanges(codePoint, wideRanges) ? 2 : 1;
|
||||
if (width === 0) {
|
||||
let previous = this.col - 1;
|
||||
if (line[previous]?.continuation) previous -= 1;
|
||||
if (previous >= 0 && line[previous]) {
|
||||
line[previous] = { ...line[previous], ch: line[previous].ch + ch };
|
||||
} else {
|
||||
line[this.col] = createCell((line[this.col]?.width === 0 ? line[this.col].ch : "") + ch,
|
||||
this.style, { width: 0 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prefix = line[this.col]?.width === 0 ? line[this.col].ch : "";
|
||||
for (let offset = 0; offset < width; offset += 1) this.#clearWideCellAt(line, this.col + offset);
|
||||
line[this.col] = createCell(prefix + ch, this.style, { width });
|
||||
if (width === 2) line[this.col + 1] = createCell("", this.style, { continuation: true });
|
||||
this.col += width;
|
||||
}
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = false;
|
||||
}
|
||||
|
||||
#clearWideCellAt(line, column) {
|
||||
const start = line[column]?.continuation ? column - 1 : column;
|
||||
if (line[start]?.width !== 2) return;
|
||||
// Overwriting either half removes the glyph. Preserve evidence that both
|
||||
// blank cells came from text, so a following readline erase can certify it.
|
||||
for (let i = start; i <= start + 1; i += 1) {
|
||||
line[i] = createCell(" ", createDefaultStyle());
|
||||
}
|
||||
}
|
||||
|
||||
#eraseLine(mode) {
|
||||
this.#ensureLine();
|
||||
const line = this.lines[this.row];
|
||||
@@ -377,6 +422,7 @@ class TerminalTextRenderer {
|
||||
if (changesContent && this.timestamp !== undefined) line.timestamp = this.timestamp;
|
||||
if (mode === 1) {
|
||||
for (let i = 0; i <= this.col && i < line.length; i += 1) {
|
||||
this.#clearWideCellAt(line, i);
|
||||
line[i] = createCell(" ", createDefaultStyle());
|
||||
}
|
||||
return;
|
||||
@@ -385,6 +431,7 @@ class TerminalTextRenderer {
|
||||
this.lines[this.row] = [];
|
||||
return;
|
||||
}
|
||||
if (line[this.col]?.continuation) this.#clearWideCellAt(line, this.col);
|
||||
line.length = Math.min(line.length, this.col);
|
||||
}
|
||||
|
||||
@@ -521,6 +568,10 @@ function terminalDataToPlainText(terminalData) {
|
||||
}
|
||||
|
||||
function terminalCursorLine(terminalData, options) {
|
||||
return terminalCursorState(terminalData, options).line;
|
||||
}
|
||||
|
||||
function terminalCursorState(terminalData, options) {
|
||||
// Prompt tracking keeps a rolling tail, so the alternate-screen entry may
|
||||
// already have scrolled off. Seed the mode that was active at the first
|
||||
// byte still in that tail; a fresh renderer would otherwise treat TUI paint
|
||||
@@ -532,8 +583,11 @@ function terminalCursorLine(terminalData, options) {
|
||||
renderer.finish();
|
||||
// A full-screen program owns the display. The main buffer may still hold
|
||||
// the shell prompt from before the alternate screen was entered.
|
||||
if (renderer.alternateScreenActive) return "";
|
||||
return renderer.getCursorLine();
|
||||
if (renderer.alternateScreenActive) return { line: "", column: 0 };
|
||||
return {
|
||||
line: renderer.getCursorLine(),
|
||||
column: renderer.col,
|
||||
};
|
||||
}
|
||||
|
||||
// Alternate-screen transitions from TerminalTextRenderer, without a text
|
||||
@@ -620,6 +674,7 @@ module.exports = {
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
terminalCursorLine,
|
||||
terminalCursorState,
|
||||
alternateScreenActiveAfter,
|
||||
};
|
||||
|
||||
@@ -634,16 +689,21 @@ function createDefaultStyle() {
|
||||
};
|
||||
}
|
||||
|
||||
function createCell(ch, style) {
|
||||
function createCell(ch, style, options = {}) {
|
||||
return {
|
||||
ch,
|
||||
style,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function cellText(cell) {
|
||||
return cell?.continuation ? "" : cell?.ch || " ";
|
||||
}
|
||||
|
||||
function cloneLines(lines) {
|
||||
return lines.map((line) => {
|
||||
const clone = line.map((cell) => (cell ? createCell(cell.ch, cell.style) : cell));
|
||||
const clone = line.map((cell) => (cell ? { ...cell } : cell));
|
||||
if (line.timestamp !== undefined) clone.timestamp = line.timestamp;
|
||||
return clone;
|
||||
});
|
||||
@@ -673,6 +733,7 @@ function renderLineHtml(line) {
|
||||
const trimmedLength = getTrimmedLineLength(line);
|
||||
for (let i = 0; i < trimmedLength; i += 1) {
|
||||
const cell = line[i] || createCell(" ", createDefaultStyle());
|
||||
if (cell.continuation) continue;
|
||||
if (!runStyle || !stylesEqual(runStyle, cell.style)) {
|
||||
flush();
|
||||
runStyle = cell.style;
|
||||
@@ -687,6 +748,7 @@ function getTrimmedLineLength(line) {
|
||||
let length = line.length;
|
||||
while (length > 0) {
|
||||
const cell = line[length - 1];
|
||||
if (cell?.continuation) break;
|
||||
const ch = cell?.ch || " ";
|
||||
if (ch !== " " && ch !== "\t") break;
|
||||
if (styleToCss(cell?.style)) break;
|
||||
|
||||
@@ -3,6 +3,8 @@ const assert = require("node:assert/strict");
|
||||
const {
|
||||
createTerminalTextRenderer,
|
||||
terminalDataToPlainText,
|
||||
terminalCursorLine,
|
||||
terminalCursorState,
|
||||
} = require("./terminalLogSanitizer.cjs");
|
||||
const { terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
|
||||
@@ -10,6 +12,62 @@ test("plain text rendering applies backspace edits", () => {
|
||||
assert.equal(terminalDataToPlainText("hellp\bo\n"), "hello");
|
||||
});
|
||||
|
||||
test("cursor state preserves ambiguous blank replacement output", () => {
|
||||
const prompt = "prompt> ";
|
||||
for (const width of [1, 2, 5, 11]) {
|
||||
const output = prompt + "x".repeat(width) + "\b".repeat(width)
|
||||
+ " ".repeat(width) + "\b".repeat(width);
|
||||
const expected = { line: prompt + " ".repeat(width), column: prompt.length };
|
||||
assert.deepEqual(terminalCursorState(output), expected);
|
||||
const renderer = createTerminalTextRenderer();
|
||||
for (const ch of output) renderer.feed(ch);
|
||||
assert.equal(renderer.getCursorLine(), expected.line);
|
||||
}
|
||||
assert.deepEqual(terminalCursorState(`${prompt}\x1b[?1049halt`), { line: "", column: 0 });
|
||||
});
|
||||
|
||||
test("cursor columns use display widths without inserting padding into log text", () => {
|
||||
const text = "\u754c\u{1f600}e\u0301";
|
||||
assert.deepEqual(terminalCursorState(text), { line: text, column: 5 });
|
||||
assert.equal(terminalDataToPlainText(text), text);
|
||||
const renderer = createTerminalTextRenderer();
|
||||
for (const ch of text) renderer.feed(ch);
|
||||
assert.equal(renderer.getCursorLine(), text);
|
||||
assert.equal(renderer.toHtmlContent().replace(/<[^>]*>/g, ""), text);
|
||||
});
|
||||
|
||||
test("wide-cell overwrites, erases and preserved screens render complete glyphs", () => {
|
||||
assert.equal(terminalDataToPlainText("\u754cA\rX"), "X A");
|
||||
assert.equal(terminalDataToPlainText("\u754cA\r\x1b[CX"), " XA");
|
||||
assert.equal(terminalDataToPlainText("A\u754cB\r\x1b[2CX"), "A XB");
|
||||
assert.equal(terminalDataToPlainText("\u754cA\r\x1b[C\x1b[K"), "");
|
||||
assert.equal(terminalDataToPlainText("\u754c\x1b[2J\x1b[H\u65b0"), "\u754c\n\u65b0");
|
||||
});
|
||||
|
||||
test("wide and combining erasures preserve displayed blanks", () => {
|
||||
const prompt = "\u754c> ";
|
||||
for (const text of ["\u754c", "\u{1f600}"]) {
|
||||
const erased = prompt + text + "\b\b \b\b";
|
||||
assert.deepEqual(terminalCursorState(erased), {
|
||||
line: prompt + " ", column: 4,
|
||||
});
|
||||
}
|
||||
assert.equal(terminalCursorState(prompt + "e\u0301\b \b").line, prompt + " ");
|
||||
const partial = terminalCursorState(prompt + "\u{1f44d}\u{1f3fd}\b\b \b\b");
|
||||
assert.equal(partial.column, 6);
|
||||
assert.equal(partial.line, prompt + "\u{1f44d} ");
|
||||
});
|
||||
|
||||
test("spacing marks and soft hyphens remain separate input cells", () => {
|
||||
for (const suffix of ["\u093e", "\u00ad"]) {
|
||||
assert.equal(terminalCursorState("x" + suffix).column, 2);
|
||||
const afterDelete = terminalCursorState("x" + suffix + "\b \b");
|
||||
assert.equal(afterDelete.column, 1);
|
||||
assert.equal(afterDelete.line, "x ");
|
||||
}
|
||||
assert.equal(terminalCursorState("\u1100\u1161\u11a8").column, 2);
|
||||
});
|
||||
|
||||
test("plain text rendering applies carriage-return overwrites", () => {
|
||||
assert.equal(terminalDataToPlainText("progress 10%\rprogress 100%\n"), "progress 100%");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Generated by node scripts/generate-terminal-width-data.cjs
|
||||
// Unicode 17.0.0 W/F data from get-east-asian-width 1.5.0:
|
||||
// https://github.com/sindresorhus/get-east-asian-width/tree/v1.5.0
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const fullwidthRanges = [
|
||||
12288, 12288, 65281, 65376, 65504, 65510,
|
||||
];
|
||||
|
||||
const wideRanges = [
|
||||
4352, 4447, 8986, 8987, 9001, 9002, 9193, 9196, 9200, 9200, 9203, 9203,
|
||||
9725, 9726, 9748, 9749, 9776, 9783, 9800, 9811, 9855, 9855, 9866, 9871,
|
||||
9875, 9875, 9889, 9889, 9898, 9899, 9917, 9918, 9924, 9925, 9934, 9934,
|
||||
9940, 9940, 9962, 9962, 9970, 9971, 9973, 9973, 9978, 9978, 9981, 9981,
|
||||
9989, 9989, 9994, 9995, 10024, 10024, 10060, 10060, 10062, 10062, 10067, 10069,
|
||||
10071, 10071, 10133, 10135, 10160, 10160, 10175, 10175, 11035, 11036, 11088, 11088,
|
||||
11093, 11093, 11904, 11929, 11931, 12019, 12032, 12245, 12272, 12287, 12289, 12350,
|
||||
12353, 12438, 12441, 12543, 12549, 12591, 12593, 12686, 12688, 12773, 12783, 12830,
|
||||
12832, 12871, 12880, 42124, 42128, 42182, 43360, 43388, 44032, 55203, 63744, 64255,
|
||||
65040, 65049, 65072, 65106, 65108, 65126, 65128, 65131, 94176, 94180, 94192, 94198,
|
||||
94208, 101589, 101631, 101662, 101760, 101874, 110576, 110579, 110581, 110587, 110589, 110590,
|
||||
110592, 110882, 110898, 110898, 110928, 110930, 110933, 110933, 110948, 110951, 110960, 111355,
|
||||
119552, 119638, 119648, 119670, 126980, 126980, 127183, 127183, 127374, 127374, 127377, 127386,
|
||||
127488, 127490, 127504, 127547, 127552, 127560, 127568, 127569, 127584, 127589, 127744, 127776,
|
||||
127789, 127797, 127799, 127868, 127870, 127891, 127904, 127946, 127951, 127955, 127968, 127984,
|
||||
127988, 127988, 127992, 128062, 128064, 128064, 128066, 128252, 128255, 128317, 128331, 128334,
|
||||
128336, 128359, 128378, 128378, 128405, 128406, 128420, 128420, 128507, 128591, 128640, 128709,
|
||||
128716, 128716, 128720, 128722, 128725, 128728, 128732, 128735, 128747, 128748, 128756, 128764,
|
||||
128992, 129003, 129008, 129008, 129292, 129338, 129340, 129349, 129351, 129535, 129648, 129660,
|
||||
129664, 129674, 129678, 129734, 129736, 129736, 129741, 129756, 129759, 129770, 129775, 129784,
|
||||
131072, 196605, 196608, 262141,
|
||||
];
|
||||
|
||||
module.exports = { fullwidthRanges, wideRanges };
|
||||
@@ -84,8 +84,8 @@ const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
// Small displays shrink this fit to their work area at window-creation time
|
||||
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
|
||||
// the minimum tracks the display the window currently occupies.
|
||||
const MIN_WINDOW_WIDTH = 960;
|
||||
const MIN_WINDOW_HEIGHT = 540;
|
||||
const MIN_WINDOW_WIDTH = 1100;
|
||||
const MIN_WINDOW_HEIGHT = 640;
|
||||
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG_WINDOWS) return;
|
||||
|
||||
@@ -7,7 +7,7 @@ const perfTrace = require("../../perfTrace.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
// #3647: the layout minimum can be larger than the usable workspace on
|
||||
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
|
||||
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
|
||||
// logical px), which made those windows impossible to fit — let alone shrink.
|
||||
// Keep the layout minimum on capable screens, but shrink it to the work area
|
||||
@@ -33,19 +33,14 @@ function pickTargetDisplay(screenModule, bounds) {
|
||||
}
|
||||
|
||||
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
|
||||
let display;
|
||||
try {
|
||||
display = bounds ? pickTargetDisplay(screenModule, bounds) : screenModule?.getPrimaryDisplay?.();
|
||||
} catch {
|
||||
// screen unavailable (e.g. before app ready): keep the static minimums
|
||||
}
|
||||
return minimumSizeForDisplay(display, minWidth, minHeight);
|
||||
}
|
||||
|
||||
function minimumSizeForDisplay(display, minWidth, minHeight) {
|
||||
let effectiveWidth = minWidth;
|
||||
let effectiveHeight = minHeight;
|
||||
try {
|
||||
// Size for the display the window actually lives on (saved position or
|
||||
// current placement), falling back to the primary display.
|
||||
const display = bounds
|
||||
? pickTargetDisplay(screenModule, bounds)
|
||||
: screenModule?.getPrimaryDisplay?.();
|
||||
const workArea = display?.workArea;
|
||||
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
|
||||
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
|
||||
@@ -62,52 +57,6 @@ function minimumSizeForDisplay(display, minWidth, minHeight) {
|
||||
};
|
||||
}
|
||||
|
||||
function intersectsBounds(a, b) {
|
||||
return b && a.x < b.x + b.width && a.x + a.width > b.x
|
||||
&& a.y < b.y + b.height && a.y + a.height > b.y;
|
||||
}
|
||||
|
||||
// Initial normal bounds only: live display recovery and manual placement keep
|
||||
// their existing lifecycle. Electron screen coordinates are already DIPs.
|
||||
function resolveStartupBounds(screenModule, savedState, defaults, minWidth, minHeight) {
|
||||
const savedPosition = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? savedState : null;
|
||||
let display;
|
||||
try {
|
||||
display = screenModule?.getPrimaryDisplay?.();
|
||||
if (savedPosition) {
|
||||
const matched = pickTargetDisplay(screenModule, savedPosition);
|
||||
// getDisplayMatching returns the nearest screen even for removed displays.
|
||||
// Only select it when the saved rectangle actually intersects that screen.
|
||||
if (intersectsBounds(savedPosition, matched?.bounds)) display = matched;
|
||||
}
|
||||
} catch {
|
||||
// Keep static sizes when Electron cannot supply a display.
|
||||
}
|
||||
const minimum = minimumSizeForDisplay(display, minWidth, minHeight);
|
||||
const workArea = display?.workArea;
|
||||
const bounds = {};
|
||||
for (const axis of ["width", "height"]) {
|
||||
const intended = savedState?.[axis] ?? defaults[axis];
|
||||
const available = workArea?.[axis];
|
||||
bounds[axis] = Math.max(minimum[axis], Number.isFinite(available) && available > 0 && intended > available
|
||||
? Math.floor(available * MIN_SIZE_FIT_FACTOR)
|
||||
: intended);
|
||||
}
|
||||
if (savedPosition && workArea) {
|
||||
bounds.x = savedPosition.x;
|
||||
bounds.y = savedPosition.y;
|
||||
// Preserve all usable placements, including negative monitor origins and
|
||||
// partial overlap. Resizing can make a previously visible position unusable.
|
||||
if (!intersectsBounds(bounds, workArea)) {
|
||||
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
|
||||
bounds[axis] = Math.max(workArea[axis], Math.min(bounds[axis], workArea[axis] + workArea[size] - bounds[size]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { bounds, minimum };
|
||||
}
|
||||
|
||||
// Keep the BrowserWindow minimum in sync with the display it currently lives
|
||||
// on: docking, unplugging a screen, or display-scaling changes can move the
|
||||
// window onto a display with a different work area.
|
||||
@@ -258,14 +207,56 @@ function createMainWindowApi(ctx) {
|
||||
|
||||
// Load saved window state
|
||||
const savedState = persistWindowState ? loadWindowState() : null;
|
||||
const { bounds: windowBounds, minimum: windowMinimumSize } = resolveStartupBounds(
|
||||
let windowBounds = {
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
};
|
||||
|
||||
// Size the minimum to the display the restored window will live on
|
||||
// (a saved position may target a smaller secondary display), falling
|
||||
// back to the primary display when there is no usable saved position.
|
||||
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
|
||||
: null;
|
||||
const windowMinimumSize = resolveWindowMinimumSize(
|
||||
screen,
|
||||
savedState,
|
||||
{ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT },
|
||||
MIN_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
savedTargetBounds,
|
||||
);
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions, but clamp to the minimum so a previously
|
||||
// shrunk window from an older build cannot start below the minimum.
|
||||
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
|
||||
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
try {
|
||||
// Check if the saved position is within any available display
|
||||
const displays = screen?.getAllDisplays?.() || [];
|
||||
const isPositionVisible = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds;
|
||||
// Check if at least part of the window would be visible on this display
|
||||
return (
|
||||
savedState.x < x + width &&
|
||||
savedState.x + savedState.width > x &&
|
||||
savedState.y < y + height &&
|
||||
savedState.y + savedState.height > y
|
||||
);
|
||||
});
|
||||
|
||||
if (isPositionVisible) {
|
||||
windowBounds.x = savedState.x;
|
||||
windowBounds.y = savedState.y;
|
||||
}
|
||||
} catch {
|
||||
// Ignore screen check errors, just don't set position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
|
||||
|
||||
perfTrace.mark("main:create-window");
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMainWindowApi } = require("./mainWindow.cjs");
|
||||
|
||||
const large = {
|
||||
id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
workArea: { x: 0, y: 0, width: 1920, height: 1040 },
|
||||
};
|
||||
const small = {
|
||||
id: 2, bounds: { x: 0, y: 0, width: 1093, height: 614 },
|
||||
workArea: { x: 0, y: 0, width: 1093, height: 585 },
|
||||
};
|
||||
const intersects = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x
|
||||
&& a.y < b.y + b.height && a.y + a.height > b.y;
|
||||
|
||||
// Exercise the real constructor and lifecycle with recorded native calls.
|
||||
async function create(saved, primary = large, displays = [primary], matching) {
|
||||
const writes = [];
|
||||
const screen = new EventEmitter();
|
||||
screen.getPrimaryDisplay = () => primary;
|
||||
screen.getAllDisplays = () => displays;
|
||||
screen.getDisplayMatching = matching || ((bounds) => displays.find((d) => intersects(bounds, d.bounds)) || displays.at(-1));
|
||||
let options;
|
||||
class Window extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super();
|
||||
options = opts;
|
||||
this.bounds = { x: opts.x ?? primary?.workArea.x ?? 0, y: opts.y ?? primary?.workArea.y ?? 0, width: opts.width, height: opts.height };
|
||||
this.minimum = [opts.minWidth, opts.minHeight];
|
||||
this.maximized = false;
|
||||
this.fullscreen = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71, isDestroyed: () => false, setWindowOpenHandler() {}, send() {},
|
||||
});
|
||||
}
|
||||
isDestroyed() { return false; }
|
||||
isMaximized() { return this.maximized; }
|
||||
isFullScreen() { return this.fullscreen; }
|
||||
isMinimized() { return this.minimized === true; }
|
||||
getBounds() { return { ...this.bounds }; }
|
||||
getMinimumSize() { return this.minimum; }
|
||||
setMinimumSize(w, h) { this.minimum = [w, h]; }
|
||||
maximize() { this.maximized = true; }
|
||||
setFullScreen(value) { this.fullscreen = value; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
close() {}
|
||||
}
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: true,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 960, MIN_WINDOW_HEIGHT: 540,
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return saved; },
|
||||
getWindowBoundsState(win, normal) {
|
||||
return { ...(normal || win.getBounds()), isMaximized: win.isMaximized(), isFullScreen: win.isFullScreen() };
|
||||
},
|
||||
queueWindowStateSave: (state) => writes.push(state), saveWindowStateSync: (state) => writes.push(state),
|
||||
setupDeferredShow() {}, createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, registerMainWindow() {}, unregisterMainWindow() {},
|
||||
closeSettingsWindow() {}, applyWindowOpacityToWindow() {},
|
||||
});
|
||||
const win = await api.createWindow(
|
||||
{ BrowserWindow: Window, nativeTheme: {}, app: {}, screen, shell: {}, ipcMain: {} },
|
||||
{ preload: "/unused/preload.cjs", isDev: false, isMac: true, electronDir: __dirname },
|
||||
);
|
||||
assert.deepEqual(writes, [], "calculating startup bounds must not persist settings");
|
||||
return { win, options, screen, writes };
|
||||
}
|
||||
const geometry = (o) => [o.width, o.height, o.minWidth, o.minHeight];
|
||||
|
||||
test("ordinary large-display default stays 1400x900 with 960x540 minimum", async () => {
|
||||
assert.deepEqual(geometry((await create(null)).options), [1400, 900, 960, 540]);
|
||||
});
|
||||
|
||||
test("fresh small-display default fits each overflowing axis", async () => {
|
||||
assert.deepEqual(geometry((await create(null, small)).options), [983, 526, 960, 526]);
|
||||
});
|
||||
|
||||
test("huge saved bounds fit the target work area without mutating saved settings", async () => {
|
||||
const saved = Object.freeze({ x: 10, y: 20, width: 4000, height: 3000 });
|
||||
const { options } = await create(saved, small);
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
assert.deepEqual([options.x, options.y], [10, 20]);
|
||||
});
|
||||
|
||||
test("fitting dimensions and usable partial placements remain exact", async () => {
|
||||
for (const [x, y] of [[30, 15], [-20, -10], [1000, 500]]) {
|
||||
const { options } = await create({ x, y, width: 1000, height: 560 }, small);
|
||||
assert.deepEqual([options.x, options.y, options.width, options.height], [x, y, 1000, 560]);
|
||||
}
|
||||
});
|
||||
|
||||
test("only overflowing axes shrink; undersized axes rise to the effective minimum", async () => {
|
||||
for (const [width, height, expected] of [
|
||||
[1600, 560, [983, 560]], [1000, 1000, [1000, 526]],
|
||||
[800, 400, [960, 526]], [1093, 585, [1093, 585]],
|
||||
]) {
|
||||
const { options } = await create({ x: 0, y: 0, width, height }, small);
|
||||
assert.deepEqual([options.width, options.height], expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("workArea offsets/taskbars recover inaccessible positions only", async () => {
|
||||
const display = { ...small, workArea: { x: 80, y: 40, width: 1013, height: 545 } };
|
||||
const { options } = await create({ x: -950, y: 0, width: 1000, height: 540 }, display);
|
||||
assert.deepEqual([options.x, options.y, options.width, options.height], [80, 40, 1000, 540]);
|
||||
const usable = (await create({ x: 90, y: 42, width: 1000, height: 540 }, display)).options;
|
||||
assert.deepEqual([usable.x, usable.y, usable.width, usable.height], [90, 42, 1000, 540]);
|
||||
});
|
||||
|
||||
test("negative secondary origin selects that display and preserves valid position", async () => {
|
||||
const secondary = { ...small, bounds: { ...small.bounds, x: -1093 }, workArea: { ...small.workArea, x: -1093 } };
|
||||
const { options } = await create({ x: -1080, y: 15, width: 1000, height: 560 }, large, [large, secondary]);
|
||||
assert.deepEqual([options.x, options.y, ...geometry(options)], [-1080, 15, 1000, 560, 960, 526]);
|
||||
});
|
||||
|
||||
test("removed saved screen falls back to primary even when Electron matches the nearest secondary", async () => {
|
||||
const secondary = { ...large, id: 3, bounds: { ...large.bounds, x: 1920 }, workArea: { ...large.workArea, x: 1920 } };
|
||||
const { options } = await create({ x: 8000, y: 100, width: 4000, height: 3000 }, small, [small, secondary], () => secondary);
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
assert.ok(intersects({ ...options }, small.workArea));
|
||||
});
|
||||
|
||||
test("DIP startup sizing does not rescale at 1, 1.25, 1.5 or 2", async () => {
|
||||
for (const scaleFactor of [1, 1.25, 1.5, 2]) {
|
||||
const { options } = await create(null, { ...small, scaleFactor });
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
}
|
||||
});
|
||||
|
||||
test("representative work areas retain the adaptive 90 percent minimum and emergency floor", async () => {
|
||||
for (const [width, height, expected] of [
|
||||
[1366, 728, [1229, 655, 960, 540]], [1280, 680, [1152, 612, 960, 540]],
|
||||
[960, 540, [864, 486, 864, 486]], [300, 200, [320, 240, 320, 240]],
|
||||
]) {
|
||||
const display = { bounds: { x: 0, y: 0, width, height }, workArea: { x: 0, y: 0, width, height } };
|
||||
assert.deepEqual(geometry((await create(null, display)).options), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy dimension-only state and unavailable screen keep usable dimensions", async () => {
|
||||
assert.deepEqual(geometry((await create({ width: 1000, height: 560 }, small)).options), [1000, 560, 960, 526]);
|
||||
const { options } = await create({ width: 800, height: 400 }, null, []);
|
||||
assert.deepEqual(geometry(options), [960, 540, 960, 540]);
|
||||
});
|
||||
|
||||
test("saved flags retain existing startup semantics with fitted normal bounds and no startup write", async () => {
|
||||
for (const [isMaximized, isFullScreen] of [[true, false], [false, true], [true, true]]) {
|
||||
const { win, options, writes } = await create({ x: 10, y: 20, width: 4000, height: 3000, isMaximized, isFullScreen }, small);
|
||||
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
|
||||
win.emit("ready-to-show");
|
||||
assert.equal(win.isMaximized(), isMaximized && !isFullScreen);
|
||||
// Existing startup deliberately does not re-enter fullscreen.
|
||||
assert.equal(win.isFullScreen(), false);
|
||||
assert.deepEqual(writes, []);
|
||||
win.emit("close", { preventDefault() {} });
|
||||
win.emit("closed");
|
||||
}
|
||||
});
|
||||
|
||||
test("manual normal bounds persist across maximized/fullscreen/minimized close", async () => {
|
||||
for (const state of ["maximized", "fullscreen", "minimized"]) {
|
||||
const { win, writes } = await create(null);
|
||||
const normal = { x: 75, y: 80, width: 1040, height: 620 };
|
||||
win.bounds = normal;
|
||||
win.emit("move");
|
||||
win[state] = true;
|
||||
// A minimized window retains its normal native bounds; max/fullscreen do not.
|
||||
if (state !== "minimized") win.bounds = { x: 0, y: 0, width: 1920, height: 1080 };
|
||||
win.emit("resize");
|
||||
win.emit("close", { preventDefault() {} });
|
||||
assert.deepEqual(writes.at(-1), { ...normal, isMaximized: state === "maximized", isFullScreen: state === "fullscreen" });
|
||||
win.emit("closed");
|
||||
}
|
||||
});
|
||||
|
||||
test("live minimum sync moves between displays, recovers after removal, and detaches on close", async () => {
|
||||
const secondary = { ...small, bounds: { ...small.bounds, x: -1093 }, workArea: { ...small.workArea, x: -1093 } };
|
||||
const { win, screen } = await create(null, large, [large, secondary]);
|
||||
win.bounds = { x: -1080, y: 0, width: 1000, height: 560 };
|
||||
win.emit("move");
|
||||
assert.deepEqual(win.minimum, [960, 526]);
|
||||
screen.getDisplayMatching = () => large;
|
||||
screen.emit("display-removed");
|
||||
assert.deepEqual(win.minimum, [960, 540]);
|
||||
screen.getDisplayMatching = () => small;
|
||||
screen.emit("display-metrics-changed");
|
||||
assert.deepEqual(win.minimum, [960, 526]);
|
||||
win.emit("close", { preventDefault() {} });
|
||||
win.emit("closed");
|
||||
assert.equal(screen.listenerCount("display-removed"), 0);
|
||||
assert.equal(screen.listenerCount("display-metrics-changed"), 0);
|
||||
});
|
||||
@@ -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,210 @@
|
||||
"""Capture real Bash readline output for OSC readiness regression tests."""
|
||||
import json
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
env = dict(os.environ)
|
||||
available_locales = subprocess.run(["locale", "-a"], capture_output=True, text=True).stdout.splitlines()
|
||||
utf8_locale = next((name for name in available_locales if name.lower().replace("-", "").endswith("utf8")), None)
|
||||
if utf8_locale is None:
|
||||
sys.exit(77)
|
||||
prompt_text = "review@\u754c\u4e3b\u673a:~$ " if "--wide-prompt" in sys.argv else "review@local:~$ "
|
||||
env.update(
|
||||
PS1=r"\[\e]133;A\a\]" + prompt_text + r"\[\e]133;B\a\]",
|
||||
PS2="> ", HISTFILE="/dev/null", TERM="dumb", LC_ALL=utf8_locale,
|
||||
INPUTRC="/dev/null", PROMPT_COMMAND="",
|
||||
)
|
||||
def prepare_pty_child():
|
||||
os.setsid()
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
signal.signal(signal.SIGQUIT, signal.SIG_DFL)
|
||||
signal.pthread_sigmask(signal.SIG_SETMASK, [])
|
||||
|
||||
|
||||
|
||||
|
||||
def drain(expected):
|
||||
data = b""
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline:
|
||||
readable, _, _ = select.select([master], [], [], 0.02)
|
||||
if readable:
|
||||
data += os.read(master, 65536)
|
||||
if expected in data:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Bash PTY expected {expected!r}, got {data!r}")
|
||||
|
||||
|
||||
def readline_ready():
|
||||
# PS1 can be printed before readline has installed its terminal handlers.
|
||||
# Ctrl-G's audible abort acknowledges that the input loop processed a key.
|
||||
os.write(master, b"\x07")
|
||||
return drain(b"\x07")
|
||||
|
||||
|
||||
# Each scenario starts with a fresh prompt so an intentionally conservative
|
||||
# blank-only result cannot contaminate a later independent editing case.
|
||||
bs = lambda n: b"\b" * n
|
||||
erase = lambda n: bs(n) + b" " * n + bs(n)
|
||||
cases = [
|
||||
("character and blank deletion", [
|
||||
("type x", b"x", b"x", False), ("delete x", b"\x7f", erase(1), False),
|
||||
("type blank", b" ", b" ", False),
|
||||
("delete blank is ambiguous", b"\x7f", erase(1), False)]),
|
||||
("Ctrl-W whole word", [("type hello", b"hello", b"hello", False),
|
||||
("kill word", b"\x17", erase(5), False)]),
|
||||
("Ctrl-W leaves input", [("type two words", b"hello world", b"hello world", False),
|
||||
("kill last word", b"\x17", erase(5), False),
|
||||
("kill mixed prefix conservatively", b"\x15", erase(6), False)]),
|
||||
("Ctrl-U mixed line", [("type two words", b"hello world", b"hello world", False),
|
||||
("kill mixed line conservatively", b"\x15", erase(11), False)]),
|
||||
("Ctrl-K whole text", [("type hello", b"hello", b"hello", False),
|
||||
("home", b"\x01", bs(5), False),
|
||||
("kill suffix", b"\x0b", b" " * 5 + bs(5), False)]),
|
||||
("Ctrl-K leaves input", [("type two words", b"hello world", b"hello world", False),
|
||||
("move left", b"\x02" * 5, bs(5), False),
|
||||
("kill last word", b"\x0b", b" " * 5 + bs(5), False),
|
||||
("kill mixed prefix conservatively", b"\x15", erase(6), False)]),
|
||||
("Ctrl-K after prompt redraw", [("type two words", b"hello world", b"hello world", False),
|
||||
("home", b"\x01", bs(11), False),
|
||||
("move right redraws prompt", b"\x06" * 6, b"hello ", False),
|
||||
("kill suffix", b"\x0b", b" " * 5 + bs(5), False),
|
||||
("kill prefix with unknown suffix", b"\x15", erase(6), False)]),
|
||||
("pending blank navigation", [("type three blanks", b" ", b" ", False),
|
||||
("move left", b"\x02" * 3, bs(3), False),
|
||||
("move right", b"\x06" * 3, b" ", False),
|
||||
("move left again", b"\x02" * 3, bs(3), False),
|
||||
("kill blank suffix conservatively", b"\x0b", b" " * 3 + bs(3), False)]),
|
||||
("mixed forward delete", [("type x and blank", b"x ", b"x ", False),
|
||||
("home", b"\x01", bs(2), False),
|
||||
("delete x leaves blank", b"\x04", b" " + bs(2), False)]),
|
||||
("indistinguishable mixed line kill", [("type x and blank", b"x ", b"x ", False),
|
||||
("kill line remains ambiguous", b"\x15", erase(2), False)]),
|
||||
("repeated forward text deletion", [("type xy", b"xy", b"xy", False),
|
||||
("home", b"\x01", bs(2), False),
|
||||
("delete x leaves y", b"\x04", b"y " + bs(2), False),
|
||||
("delete y clears text", b"\x04", b" " + bs(1), False)]),
|
||||
("repeated mixed forward deletion", [("type xy and blank", b"xy ", b"xy ", False),
|
||||
("home", b"\x01", bs(3), False),
|
||||
("delete x leaves y and blank", b"\x04", b"y " + bs(3), False),
|
||||
("delete y leaves blank", b"\x04", b" " + bs(2), False)]),
|
||||
("leading blank forward deletion", [("type blank and x", b" x", b" x", False),
|
||||
("home", b"\x01", bs(2), False),
|
||||
("delete blank leaves x", b"\x04", b"x " + bs(2), False),
|
||||
("delete x clears text", b"\x04", b" " + bs(1), False)]),
|
||||
("unfinished quote", [("continue quote", b'echo "unfinished\n', b"> ", False)]),
|
||||
("wide CJK deletion", [("type wide glyph", "\u754c".encode(), "\u754c".encode(), False),
|
||||
("delete wide glyph", b"\x7f", erase(2), False)]),
|
||||
("partial CJK deletion", [("type two wide glyphs", "\u4e2d\u6587".encode(), "\u4e2d\u6587".encode(), False),
|
||||
("delete one keeps input", b"\x7f", erase(2), False),
|
||||
("delete remainder", b"\x7f", erase(2), False)]),
|
||||
("combining mark deletion", [("type combining text", "e\u0301".encode(), "e\u0301".encode(), False),
|
||||
("delete composed text", b"\x7f", erase(1), False)]),
|
||||
("emoji deletion", [("type emoji", "\U0001f600".encode(), "\U0001f600".encode(), False),
|
||||
("delete emoji", b"\x7f", erase(2), False)]),
|
||||
("partial emoji modifier deletion", [("type modified emoji", "\U0001f44d\U0001f3fd".encode(), "\U0001f44d\U0001f3fd".encode(), False),
|
||||
("delete modifier keeps base", b"\x7f", erase(2), False)]),
|
||||
("partial joined emoji deletion", [("type joined emoji", "\U0001f468\u200d\U0001f469\u200d\U0001f467".encode(), "\U0001f468\u200d\U0001f469\u200d\U0001f467".encode(), False),
|
||||
("delete last emoji keeps prefix", b"\x7f", erase(2), False)]),
|
||||
("partial flag deletion", [("type flag", "\U0001f1e8\U0001f1f3".encode(), "\U0001f1e8\U0001f1f3".encode(), False),
|
||||
("delete one indicator keeps input", b"\x7f", erase(1), False)]),
|
||||
("spacing mark deletion", [("type base and spacing mark", "x\u093e".encode(), "x\u093e".encode(), False),
|
||||
("delete spacing mark keeps base", b"\x7f", erase(1), False)]),
|
||||
("soft hyphen deletion", [("type base and soft hyphen", "x\u00ad".encode(), "x\u00ad".encode(), False),
|
||||
("delete soft hyphen keeps base", b"\x7f", erase(1), False)]),
|
||||
("decomposed Hangul deletion", [("type decomposed syllable", "\u1100\u1161\u11a8".encode(), "\u1100\u1161\u11a8".encode(), False),
|
||||
("delete syllable", b"\x7f", erase(2), False)]),
|
||||
("zero width space deletion", [("type base and zero width space", "x\u200b".encode(), "x\u200b".encode(), False),
|
||||
("delete combined text", b"\x7f", erase(1), False)]),
|
||||
("invisible format deletion", [("type base and letter mark", "x\u061c".encode(), "x\u061c".encode(), False),
|
||||
("delete combined text", b"\x7f", erase(1), False)]),
|
||||
("variation selector deletion", [("type base and selector", "x\ufe0f".encode(), "x\ufe0f".encode(), False),
|
||||
("delete combined text", b"\x7f", erase(1), False)]),
|
||||
]
|
||||
|
||||
risk_probes = "--risk-probes" in sys.argv
|
||||
rc_directory = tempfile.TemporaryDirectory(prefix="netcatty-readline-")
|
||||
rc_path = os.path.join(rc_directory.name, "bashrc")
|
||||
with open(rc_path, "w", encoding="ascii") as rc:
|
||||
rc.write(r'''PS1=$NETCATTY_TEST_PS1
|
||||
PS2="> "
|
||||
__capture_readline() { printf '\n__READLINE_BEGIN__%s__READLINE_END__\n' "$READLINE_LINE"; }
|
||||
bind -m vi-command -x '"\C-x\C-p":__capture_readline'
|
||||
bind -m vi-insert '"\C-g":abort'
|
||||
bind -m vi-insert -x '"\C-x\C-p":__capture_readline'
|
||||
bind -m emacs-standard -x '"\C-x\C-p":__capture_readline'
|
||||
''')
|
||||
|
||||
scenarios = []
|
||||
|
||||
|
||||
def interrupted(_signal, _frame):
|
||||
raise RuntimeError("Bash fixture interrupted")
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
if risk_probes:
|
||||
cases = [
|
||||
("vi replacement leaves genuine blank", [
|
||||
("type x", b"x", b"x", False),
|
||||
("vi replace x with blank", b"\x1br ", b"\b \b", False)]),
|
||||
("multiline prompt Ctrl-L redraw", [
|
||||
("type pending command", b"echo pending", b"echo pending", False),
|
||||
("redraw multiline PS1", b"\x0c", b"\x1b]133;B\x07", False)]),
|
||||
]
|
||||
|
||||
for name, steps in cases:
|
||||
master, slave = pty.openpty()
|
||||
shell = None
|
||||
try:
|
||||
case_env = dict(env)
|
||||
if risk_probes and name.startswith("vi"):
|
||||
with open(rc_path, "a", encoding="ascii") as rc:
|
||||
rc.write("set -o vi\n")
|
||||
if risk_probes and name.startswith("multiline"):
|
||||
case_env["TERM"] = "xterm-256color"
|
||||
case_env["PS1"] = r"\[\e]133;A\a\]header\n" + prompt_text + r"\[\e]133;B\a\]"
|
||||
# Select the normal emacs map after the preceding vi scenario.
|
||||
with open(rc_path, "a", encoding="ascii") as rc:
|
||||
rc.write("set -o emacs\n")
|
||||
case_env["NETCATTY_TEST_PS1"] = case_env["PS1"]
|
||||
shell = subprocess.Popen(
|
||||
["/bin/bash", "--noprofile", "--rcfile", rc_path, "-i"],
|
||||
stdin=slave, stdout=slave, stderr=slave, env=case_env, preexec_fn=prepare_pty_child,
|
||||
)
|
||||
os.close(slave)
|
||||
slave = None
|
||||
prompt = drain(b"\x1b]133;B\x07") + readline_ready()
|
||||
states = [dict(step="fresh prompt", raw=prompt, ready=True)]
|
||||
for step, keys, expected, ready in steps:
|
||||
os.write(master, keys)
|
||||
try:
|
||||
raw = drain(expected)
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError(f"{name}: {step}") from error
|
||||
states.append(dict(step=step, raw=raw, ready=ready))
|
||||
scenario = dict(name=name, states=states)
|
||||
if risk_probes:
|
||||
os.write(master, b"\x18\x10")
|
||||
proof = drain(b"__READLINE_END__")
|
||||
scenario["readline_line"] = proof.split("__READLINE_BEGIN__", 1)[1].split("__READLINE_END__", 1)[0]
|
||||
scenarios.append(scenario)
|
||||
finally:
|
||||
if shell is not None:
|
||||
shell.kill()
|
||||
shell.wait(timeout=3)
|
||||
if slave is not None:
|
||||
os.close(slave)
|
||||
os.close(master)
|
||||
|
||||
rc_directory.cleanup()
|
||||
print(json.dumps(scenarios))
|
||||
@@ -0,0 +1,26 @@
|
||||
// Regenerate using the dev dependencies in the current lockfile.
|
||||
// Production uses the generated CommonJS data, including on Node 22.0.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = path.dirname(require.resolve('get-east-asian-width'));
|
||||
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
if (version !== '1.5.0') throw new Error(`Expected locked get-east-asian-width 1.5.0; found ${version}`);
|
||||
const source = fs.readFileSync(path.join(root, 'lookup-data.js'), 'utf8');
|
||||
const license = fs.readFileSync(path.join(root, 'license'), 'utf8');
|
||||
let output = '// Generated by node scripts/generate-terminal-width-data.cjs\n'
|
||||
+ '// Unicode 17.0.0 W/F data from get-east-asian-width 1.5.0:\n'
|
||||
+ '// https://github.com/sindresorhus/get-east-asian-width/tree/v1.5.0\n'
|
||||
+ '/*\n' + license.trim() + '\n*/\n\n';
|
||||
for (const name of ['fullwidthRanges', 'wideRanges']) {
|
||||
const match = source.match(new RegExp(`const ${name} = (\\[[^\\]]+\\]);`));
|
||||
if (!match) throw new Error(`Missing source data: ${name}`);
|
||||
const values = JSON.parse(match[1]);
|
||||
output += `const ${name} = [\n`;
|
||||
for (let i = 0; i < values.length; i += 12) output += ` ${values.slice(i, i + 12).join(', ')},\n`;
|
||||
output += '];\n\n';
|
||||
}
|
||||
output += 'module.exports = { fullwidthRanges, wideRanges };\n';
|
||||
const target = path.join(__dirname, '../electron/bridges/terminalWidthData.cjs');
|
||||
if (process.argv.includes('--check')) {
|
||||
if (fs.readFileSync(target, 'utf8') !== output) throw new Error('Terminal width data is out of date');
|
||||
} else fs.writeFileSync(target, output);
|
||||
@@ -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