diff --git a/docs/README.md b/docs/README.md index bd75d03c..7894c98b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL | [Built-in browser and audit log](browser.md) | Canvas controls, settings, agent access, website/file boundaries, activity, and persistent redacted audit files | | [Installing, releases, and local data](installing-and-security.md) | Installer formats, unsigned-preview caveats, credential boundaries, and release checks | | [Widget authoring](widget-authoring.md) | Source-level extension paths, visual grammar, process boundaries, and an AI-agent brief | +| [Pixel terminal themes (Russian)](pixel-skin-packs.ru.md) | Ten-PNG ZIP layout, terminal apertures, app import, and local agent CLI | +| [Create a pixel theme with an agent](pixel-skin-agent-start.md) | Zero-context agent brief, exact install ZIP, visual checks, and validation | | [Runtime plugins](plugins.md) | Manifest v1, permissions, HOME widgets, canvas apps, separate windows, player media/playlist APIs, SDK, and install flow | | [Metrics and telemetry](metrics-and-telemetry.md) | Subscription limits, session token usage, source priority, privacy, stale states, and tests | | [Security policy](../SECURITY.md) | Supported release, vulnerability reporting, local data boundaries, plugins, media grants, browser storage, and audit logs | diff --git a/docs/README.ru.md b/docs/README.ru.md index 7833e6dd..8ad3a297 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -12,6 +12,8 @@ CanvasTTY — пространственный Electron-десктоп для н | [Встроенный браузер и журнал аудита](browser.ru.md) | Управление на канвасе, настройки, доступ агентов, границы сайтов/файлов, activity и постоянные очищенные audit-файлы | | [Установка, релизы и локальные данные](installing-and-security.ru.md) | Форматы установщиков, оговорки про неподписанное превью, границы доступа к credentials и релизные проверки | | [Создание виджетов](widget-authoring.ru.md) | Способы расширения через исходный код, визуальная грамматика, границы процессов и бриф для AI-агента | +| [Пиксельные темы терминала](pixel-skin-packs.ru.md) | Десять PNG, ZIP, прозрачный проём, импорт и управление агентом | +| [Инструкция агенту по созданию темы](pixel-skin-agent-start.md) | Задание с нулевого контекста, проверка архива и визуальная приёмка | | [Runtime-плагины](plugins.ru.md) | Manifest v1, permissions, HOME widgets, canvas apps, отдельные окна, media/playlist API для плееров, SDK и установка | | [Метрики и телеметрия](metrics-and-telemetry.ru.md) | Лимиты подписок, расход токенов за сессию, приоритет источников, приватность, stale-состояния и тесты | | [Политика безопасности](../SECURITY.ru.md) | Поддерживаемый релиз, сообщения об уязвимостях, локальные данные, плагины, медиапапки, browser storage и audit log | diff --git a/docs/pixel-skin-agent-start.md b/docs/pixel-skin-agent-start.md new file mode 100644 index 00000000..54b96ceb --- /dev/null +++ b/docs/pixel-skin-agent-start.md @@ -0,0 +1,33 @@ +# Create a CanvasTTY pixel theme with an agent + +This is the starting point for an agent with no prior CanvasTTY context. Give the agent [the design-kit repository](https://github.com/teo-nex/CanvasTTY-design-for-agents) or [its offline ZIP](https://github.com/teo-nex/CanvasTTY-design-for-agents/raw/refs/heads/main/CanvasTTY_Agent_Theme_Kit_v1.zip), plus a visual reference. The deliverable is a separate **install ZIP**, not the source-kit ZIP. Read [the full pack specification](pixel-skin-packs.ru.md) and confirm the current contract in `src/main/services/PixelSkinPackRegistry.ts` before generating art. + +## Inputs and output + +- Input: a visual reference or a written art direction, plus optional theme name. The reference is not a ready-to-import frame unless it satisfies the requirements below. +- Output: one ZIP containing **exactly ten final PNG files**. The nine frame files are `minimal_idle.png`, `minimal_working.png`, `minimal_completed.png`, `detailed_idle.png`, `detailed_working.png`, `detailed_completed.png`, `master_idle.png`, `master_working.png`, and `master_completed.png`. The tenth is `background.png`. +- A single containing folder is permitted, but there must be exactly one file for each name. Do not include drafts, alternate versions, nested source/output copies, instructions, or `apertures.json` in the install ZIP. A separate source kit is optional. +- Use `completed`, **not** `done`, in filenames. Completed covers a finished agent turn, whether it succeeded or failed. + +## Artwork contract + +1. Make all nine frames 1536 x 1024 px, PNG RGBA, on the same 3:2 canvas. Keep exterior geometry fixed across levels and states. Do not shift, crop, or rotate the frame between states. +2. The terminal output is live UI underneath the art. Leave a transparent central opening large enough for readable text. Do not bake terminal text, a black terminal panel, the cursor, title, controls, or screenshots into the image. Keep the top-right button plaque clear for native search and close buttons. +3. `minimal` is restrained; `detailed` adds visible decoration; `master` is a distinct, richer F4 view. Every level has three **visually distinguishable** states: calm `idle`, active `working`, and resolved `completed`. Maintain identical geometry within a level. Do not represent state solely with a barely visible dot or a tiny color change. +4. `background.png` is separate wallpaper for the canvas. It is not a replacement for the transparent terminal opening. Do not duplicate it under source and output paths in the install ZIP. +5. For each level report `left`, `right`, `top`, and `bottom` aperture insets as percentages of the full 1536 x 1024 frame. Check that all three states of that level use the same safe opening. The user can enter these values in the import dialog; an agent can pass them in a separate `apertures.json` via `skin-install --apertures`. Do not assume the ZIP auto-configures apertures. + +## Build and verify + +1. Inspect the reference and decide the level/state differences before rendering. Generate or edit the art, then examine all ten final images at their actual resolution. Inspect a contact sheet too, to catch geometry drift and indistinguishable states. +2. Package only the final ten PNGs. Validate the archive from the repository root: + + ```sh + node scripts/validate-pixel-skin-zip.mjs /absolute/path/theme-install.zip + ``` + + This invokes the same ZIP importer as CanvasTTY in an isolated temporary profile. It checks the exact file set and new-theme frame dimensions. A successful structural check does **not** prove the art looks good. +3. Compare the nine frames overlaid, check the transparent opening and button plaque, and preview with live terminal text in CanvasTTY when possible. Report any visual check that could not be done. +4. Deliver the install ZIP, a small preview/contact sheet, the three aperture presets, and a concise description of what changes in each state. Say whether the theme was actually installed and tested in the app; do not infer that from ZIP validation. + +For agent-driven installation in a running CanvasTTY instance, see [the local Agent Control commands](pixel-skin-packs.ru.md#установка-агентом). Installing or changing a theme affects the current user's app; verify the target instance and preserve other installed themes. diff --git a/docs/pixel-skin-packs.ru.md b/docs/pixel-skin-packs.ru.md new file mode 100644 index 00000000..16d1c170 --- /dev/null +++ b/docs/pixel-skin-packs.ru.md @@ -0,0 +1,61 @@ +# Пиксельные темы терминала + +Агенту без контекста проекта передайте [инструкцию по созданию темы](pixel-skin-agent-start.md) и визуальный референс. В настройках CanvasTTY → **Создать пиксельную тему** можно скопировать готовое задание агенту. + +Тема состоит из десяти PNG. Девять изображений образуют матрицу из трёх уровней детализации и трёх состояний; десятое служит фоном CanvasTTY. + +## Геометрия новых тем + +- Все девять рамок: **ровно 1536 × 1024 px, PNG RGBA, пропорция 3:2**. Один и тот же холст без сдвига, обрезки и поворота между состояниями. Не добавляйте вокруг рамки дополнительные поля. +- На CanvasTTY каждой рамке соответствует **фиксированная карточка 1200 × 800 логических px**. Масштаб камеры меняет её размер на экране, но не уровень картинки: настройка выбирает `minimal` или `detailed`, F4 временно включает `master`. +- В каждом уровне координаты внешней рамки, прозрачного проёма, таблички под кнопки и всех неподвижных предметов должны совпадать во всех трёх состояниях. Между уровнями сохраняйте одинаковую внешнюю посадку рамки; меняйте насыщенность декора, а не размер холста. +- Центр проёма должен быть **прозрачным**, чтобы текст настоящего терминала был виден под изображением. Не рисуйте в PNG текст, курсор, чёрный экран терминала или готовые кнопки. Не закрывайте декором проём и зону кнопок. +- `background.png` остаётся отдельным фоном рабочего поля; он не определяет геометрию терминала. Для контрольной проверки накладывайте все девять рамок друг на друга: внешний контур, проём и табличка не должны прыгать. + +Старые ZIP другого разрешения импортируются для совместимости, но могут искажаться при приведении к 1200 × 800. Новые темы готовьте строго по указанному холсту. + +| Уровень | Ожидание | Работа | Завершено | +| --- | --- | --- | --- | +| Минимальный | `minimal_idle.png` | `minimal_working.png` | `minimal_completed.png` | +| Детальный | `detailed_idle.png` | `detailed_working.png` | `detailed_completed.png` | +| Мастер, F4 | `master_idle.png` | `master_working.png` | `master_completed.png` | + +Дополнительный файл: `background.png`. Размещайте десять PNG в ZIP с этими точными именами; вложенная папка допустима. Импорт технически допускает PNG от 320 × 200 до 4096 × 4096, каждый не более 20 МБ; ZIP не более 150 МБ. Для новых рамок применяйте геометрию выше. + +**Установочный ZIP и набор исходников — разные артефакты.** В установочном ZIP оставляйте ровно десять финальных PNG. Не складывайте туда одновременно `source/background.png` и `output/background.png`: импортёр увидит повтор. Имя состояния — только `completed`, не `done`. Исходники, промежуточные рендеры, промпты и скрипты можно отдавать отдельным ZIP. + +Перед передачей архива проверьте его из корня репозитория: + +```sh +node scripts/validate-pixel-skin-zip.mjs /absolute/path/theme-install.zip +``` + +Проверка запускает реальный импортёр в отдельном временном профиле и не устанавливает тему в вашу CanvasTTY. Визуальную посадку проёма и различимость состояний нужно проверить отдельно. + +`idle` означает, что агент сейчас не выполняет ход. `working` включается во время хода. `completed` показывается после явного завершения хода, в том числе при ошибке; следующая работа снова переводит рамку в `working`. Настройка выбирает минимальную или детальную рамку, F4 временно показывает мастер-рамку выбранной темы. + +## Установка в приложении + +В настройках оформления нажмите **Создать пиксельную тему**. Можно выбрать ZIP целиком или назначить десять PNG по отдельности. При необходимости задайте отступы проёма для каждого уровня: `left`, `right`, `top`, `bottom` в процентах изображения. Все три состояния одного уровня используют одни отступы; декор не должен заходить в этот прямоугольник. После установки тема появляется в списке и сохраняется в профиле CanvasTTY. + +## Установка агентом + +Включите локальный Agent Control в CanvasTTY. CLI подключается к локальному дескриптору и не требует управления мышью. Для точной геометрии создайте JSON-файл: + +```json +{ + "minimal": { "left": 10, "right": 10, "top": 16, "bottom": 16 }, + "detailed": { "left": 14, "right": 14, "top": 18, "bottom": 19 }, + "master": { "left": 15, "right": 15, "top": 21, "bottom": 22 } +} +``` + +Из корня проекта: + +```sh +node scripts/canvastty-control.mjs skin-install --archive /absolute/path/theme.zip --name "My theme" --apertures /absolute/path/apertures.json --activate --detail detailed +node scripts/canvastty-control.mjs skin-list +node scripts/canvastty-control.mjs skin-select pixel:THEME-ID --detail minimal +``` + +`--apertures` необязателен; без него применяются консервативные отступы. ID установленной темы возвращается при импорте и доступен через `skin-list`. Команды принимают `--connection` и `--client-file` для выбора конкретного экземпляра CanvasTTY; файлы доступа должны оставаться локальными и приватными. Темы сохраняются между перезапусками. Не кладите в ZIP секреты или личные данные: его содержимое становится частью локальной темы. diff --git a/examples/terminal-skins/foundry-seven/README.md b/examples/terminal-skins/foundry-seven/README.md new file mode 100644 index 00000000..7b787df3 --- /dev/null +++ b/examples/terminal-skins/foundry-seven/README.md @@ -0,0 +1,5 @@ +# Foundry Seven + +A dark, instrument-grade bezel in graphite and aged bronze. Brushed metal edges, a fine calibration rail, and enamel-like controls add depth while keeping the terminal surface clear. The treatment is static, so reduced-motion settings need no override; keyboard focus remains visible. + +Skin ID: `custom:foundry-seven` (`foundry-seven` in the manifest). diff --git a/examples/terminal-skins/foundry-seven/manifest.json b/examples/terminal-skins/foundry-seven/manifest.json new file mode 100644 index 00000000..cae0cc10 --- /dev/null +++ b/examples/terminal-skins/foundry-seven/manifest.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "id": "foundry-seven", + "name": "Foundry Seven", + "kind": "terminal-border" +} diff --git a/examples/terminal-skins/foundry-seven/skin.css b/examples/terminal-skins/foundry-seven/skin.css new file mode 100644 index 00000000..dab71543 --- /dev/null +++ b/examples/terminal-skins/foundry-seven/skin.css @@ -0,0 +1,49 @@ +/* Foundry Seven: a warm, instrument-grade graphite and bronze bezel. */ +.terminal-card { + border: 3px solid transparent; + border-radius: 7px; + background: linear-gradient(var(--terminal-background, #202430), var(--terminal-background, #202430)) padding-box, + repeating-linear-gradient(135deg, #ead5a2 0 1px, #987547 1px 2px, #57442c 2px 4px, #b48d53 4px 6px) border-box; + box-shadow: inset 0 1px #fff0c655, inset 0 0 0 1px #17191a, + 0 0 0 1px #322719, 0 4px 0 #211a12, 0 14px 28px #000a; +} + +.terminal-card .terminal-card__header { + color: #f1e5cd; + background: repeating-linear-gradient(90deg, #fff0c514 0 1px, transparent 1px 11px) top left / 100% 2px no-repeat, + repeating-linear-gradient(90deg, #d4ad6c 0 1px, transparent 1px 9px) bottom left / 100% 3px no-repeat, + linear-gradient(180deg, #655239 0, #37332b 12%, #242624 28%, #171b1c 78%, #111312 96%, #947044 100%); + box-shadow: inset 0 1px #f7e4bd77, inset 0 -1px #090b0b; +} + +.terminal-card .terminal-card__surface { + border-top: 1px solid #735632; + box-shadow: inset 0 1px #0d1011; +} + +.terminal-card .terminal-card__actions button { + color: #f4dfb4; + border: 1px solid #a17b45; + border-radius: 4px; + background: linear-gradient(145deg, #716044 0, #40382b 42%, #1b1e1e 100%); + box-shadow: inset 0 1px #f5dda078, inset 0 -1px #111312, 0 1px 2px #000a; +} + +.terminal-card .terminal-card__actions button:hover { + color: #fff3d6; + border-color: #e1bd7b; + background: linear-gradient(145deg, #907347 0, #51432e 42%, #242625 100%); +} + +.terminal-card .terminal-card__actions button:focus-visible { + outline: 2px solid #ffe3a2; + outline-offset: 1px; + box-shadow: 0 0 0 3px #17191a, inset 0 1px #f5dda078; +} + +.terminal-card:is(:focus-within, .terminal-card--selected) { + outline: 2px solid #f3d28e; + outline-offset: 3px; + box-shadow: 0 0 0 1px #17130e, 0 0 0 5px #aa8048, + 0 4px 0 #211a12, 0 14px 28px #000a; +} diff --git a/package-lock.json b/package-lock.json index 5dcf52db..e8c85512 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "secure-remote-password": "0.3.1", + "unzipper": "^0.12.3", "yaml": "2.9.0" }, "devDependencies": { @@ -4352,7 +4353,6 @@ "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, "license": "MIT" }, "node_modules/boolean": { @@ -4727,7 +4727,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cross-dirname": { @@ -5044,7 +5043,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" @@ -6040,7 +6038,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inquirer": { @@ -6148,7 +6145,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -6943,7 +6939,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, "license": "MIT" }, "node_modules/node-pty": { @@ -7256,7 +7251,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -7403,7 +7397,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -7641,7 +7634,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -7835,7 +7827,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -8105,15 +8096,14 @@ } }, "node_modules/unzipper": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", - "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", - "dev": true, + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", "license": "MIT", "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", - "fs-extra": "11.3.1", + "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } @@ -8122,7 +8112,6 @@ "version": "11.3.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -8175,7 +8164,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/package.json b/package.json index 318fea19..f10840c5 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "secure-remote-password": "0.3.1", + "unzipper": "^0.12.3", "yaml": "2.9.0" }, "devDependencies": { diff --git a/scripts/canvastty-control.mjs b/scripts/canvastty-control.mjs index 5e2eb343..2aceae1d 100644 --- a/scripts/canvastty-control.mjs +++ b/scripts/canvastty-control.mjs @@ -106,11 +106,12 @@ export async function controlRequest({ connectionPath, clientPath, method, param export function parseArguments(argv) { const options = {}; const positional = []; - const flags = new Set(["--connection", "--client-file", "--request-id", "--cwd", "--title", "--provider", "--profile", "--prompt-file", "--text", "--after", "--choice", "--revision"]); + const flags = new Set(["--connection", "--client-file", "--request-id", "--cwd", "--title", "--provider", "--profile", "--prompt-file", "--text", "--after", "--choice", "--revision", "--archive", "--name", "--apertures", "--detail"]); for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--json") continue; if (arg === "--yolo") { options.yolo = true; continue; } + if (arg === "--activate") { options.activate = true; continue; } if (arg === "--help" || arg === "-h") { options.help = true; continue; } if (arg.startsWith("--")) { if (!flags.has(arg) || i + 1 === argv.length) throw new Error("Unknown option or missing option value."); @@ -139,6 +140,9 @@ result [--after ] interrupt choose --choice --revision dismiss --revision +skin-list +skin-install --archive [--name ] [--apertures ] [--activate] [--detail minimal|detailed] +skin-select [--detail minimal|detailed] Global options: --connection --client-file --request-id --json @@ -153,14 +157,16 @@ export async function runCli(argv) { const { options, positional } = parseArguments(argv); if (options.help) return { help: HELP }; const [method, sessionId] = positional; - if (!["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss"].includes(method)) throw new Error("Unknown command; see --help."); - if (positional.length !== (["create", "list"].includes(method) ? 1 : 2)) throw new Error("Unexpected or missing positional argument."); + if (!["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss", "skin-list", "skin-install", "skin-select"].includes(method)) throw new Error("Unknown command; see --help."); + if (positional.length !== (["create", "list", "skin-list", "skin-install"].includes(method) ? 1 : 2)) throw new Error("Unexpected or missing positional argument."); const allowed = new Set(["connection", "client-file", "request-id", ...(method === "create" ? ["cwd", "title", "provider", "profile", "yolo"] : []), ...(method === "send" ? ["prompt-file", "text"] : []), ...(method === "result" ? ["after"] : []), ...(method === "choose" ? ["choice", "revision"] : []), ...(method === "dismiss" ? ["revision"] : [])]); + if (method === "skin-install") ["archive", "name", "apertures", "activate", "detail"].forEach((key) => allowed.add(key)); + if (method === "skin-select") allowed.add("detail"); if (Object.keys(options).some((key) => !allowed.has(key))) throw new Error("Option does not apply to this command."); - let params = method === "list" ? {} : { sessionId }; + let params = ["list", "skin-list", "skin-install"].includes(method) ? {} : { sessionId }; if (method === "create") { if (!options.cwd) throw new Error("create requires --cwd."); if (options.yolo && options.profile && options.profile !== "yolo") throw new Error("Conflicting launch profiles."); @@ -184,11 +190,23 @@ export async function runCli(argv) { if (!options.revision) throw new Error("dismiss requires the observed --revision."); params.revision = options.revision; } + if (method === "skin-install") { + if (!options.archive) throw new Error("skin-install requires --archive."); + if (options.detail && !options.activate) throw new Error("--detail requires --activate."); + const apertures = options.apertures ? JSON.parse(await readFile(resolve(options.apertures), "utf8")) : undefined; + params = { archivePath: resolve(options.archive), ...(options.name ? { name: options.name } : {}), + ...(apertures ? { apertures } : {}), ...(options.activate ? { activate: true } : {}), + ...(options.detail ? { detail: options.detail } : {}) }; + } + if (method === "skin-select") { + params = { skinId: sessionId, ...(options.detail ? { detail: options.detail } : {}) }; + } const connectionPath = resolve(options.connection || defaultConnectionPath()); const clientPath = resolve(options["client-file"] || join(dirname(connectionPath), "controller.json")); const requestId = options["request-id"] || randomUUID(); try { - const result = await controlRequest({ connectionPath, clientPath, method, params, requestId }); + const result = await controlRequest({ connectionPath, clientPath, method, params, requestId, + timeoutMs: method === "skin-install" ? 120_000 : 8_000 }); return { requestId, result }; } catch (error) { error.requestId = requestId; throw error; } } diff --git a/scripts/validate-pixel-skin-zip.mjs b/scripts/validate-pixel-skin-zip.mjs new file mode 100644 index 00000000..6a8bbb3d --- /dev/null +++ b/scripts/validate-pixel-skin-zip.mjs @@ -0,0 +1,42 @@ +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { tmpdir } from "node:os"; +import { PixelSkinPackRegistry, PIXEL_SKIN_SLOTS } from "../src/main/services/PixelSkinPackRegistry.ts"; + +const require = createRequire(import.meta.url); +const unzipper = require("unzipper"); + +async function main() { + const archivePath = process.argv[2]; + if (!archivePath || process.argv.length !== 3) { + throw new Error("Usage: node scripts/validate-pixel-skin-zip.mjs /absolute/path/theme-install.zip"); + } + const archive = await readFile(archivePath); + const directory = await unzipper.Open.buffer(archive); + const actual = directory.files.filter((entry) => entry.type === "File") + .map((entry) => basename(entry.path.replace(/\\/g, "/"))).sort(); + const expected = PIXEL_SKIN_SLOTS.map((slot) => `${slot}.png`).sort(); + if (actual.length !== expected.length || actual.some((filename, index) => filename !== expected[index])) { + throw new Error(`Install ZIP must contain exactly: ${expected.join(", ")}`); + } + + const profile = await mkdtemp(join(tmpdir(), "canvastty-skin-validate-")); + try { + const registry = new PixelSkinPackRegistry(profile); + await registry.initialize(); + const pack = await registry.installZip(archive, "ZIP validation"); + const manifest = JSON.parse(await readFile(join(profile, "pixel-skins", pack.id.slice("pixel:".length), "manifest.json"), "utf8")); + if (manifest.width !== 1536 || manifest.height !== 1024) { + throw new Error(`New theme frames must be 1536x1024; found ${manifest.width}x${manifest.height}.`); + } + process.stdout.write("Valid CanvasTTY install ZIP: ten PNGs, 1536x1024 frames, importer accepted.\n"); + } finally { + await rm(profile, { recursive: true, force: true }); + } +} + +main().catch((error) => { + process.stderr.write(`Invalid CanvasTTY install ZIP: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/main/index.ts b/src/main/index.ts index 2a9358ef..55703d4e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,8 @@ import { } from "../shared/contracts"; import { registerIpc } from "./ipc/registerIpc"; import { SettingsStore } from "./services/SettingsStore"; +import { SkinRegistry } from "./services/SkinRegistry"; +import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry"; import { TerminalManager, reachesObservers, reachesRenderer } from "./services/TerminalManager"; import { TerminalRendererOutbox } from "./services/TerminalRendererOutbox"; import { AgentControlGateway } from "./services/agent-control/AgentControlGateway"; @@ -292,6 +294,10 @@ async function initializeServices(): Promise { const userDataPath = app.getPath("userData"); const settings = new SettingsStore(userDataPath, app.getLocale(), process.platform, providerCliAvailability(providerClis)); await settings.load(); + const terminalBorderSkins = new SkinRegistry(userDataPath); + await terminalBorderSkins.initialize(); + const pixelSkinPacks = new PixelSkinPackRegistry(userDataPath); + await pixelSkinPacks.initialize(); pluginManager = new PluginManager(userDataPath); await pluginManager.load(); // Secrets this app knows are masked in every text one agent reads from another (EP-8). @@ -424,6 +430,7 @@ async function initializeServices(): Promise { terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, + event: signal.event, ...(signal.turnId ? { requestId: signal.turnId } : {}), ...(signal.threadId ? { threadId: signal.threadId } : {}) }); @@ -606,8 +613,11 @@ async function initializeServices(): Promise { ? join(process.resourcesPath, "agent-browser", WINDOWS_PIPE_HOST_FILENAME) : join(app.getAppPath(), "build", "windows-agent-pipe-host", WINDOWS_PIPE_HOST_FILENAME) : undefined; - const gateway = new AgentControlGateway({ userDataPath, terminals: terminalManager, + const gateway = new AgentControlGateway({ userDataPath, terminals: terminalManager, pixelSkinPacks, settings, lifecycleEnabled: () => Boolean(runtimeGateway) && settings.get().agentLifecycleHooksEnabled, + onSettingsChanged: (updated) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(IPC.settingsChanged, updated); + }, windowsHostPath }); agentControl = gateway; try { @@ -671,6 +681,8 @@ async function initializeServices(): Promise { protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request)); observeMainWindowState = registerIpc({ settings, + terminalBorderSkins, + pixelSkinPacks, providerClis, recheckProviderClis: async () => { providerClis!.refresh(); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 3a3574e6..035be79b 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -6,10 +6,15 @@ import type { AgentCliAvailability, BrowserCommand, CanvasNavigationPointerBindingInput, + CustomTerminalBorderSkinId, CreateSessionRequest, PluginBrowserOpenResponse, PluginCanvasRequest, PluginLaunchFieldOptions, + PixelSkinPackInstallRequest, + PixelSkinZipInstallRequest, + PixelSkinSlot, + PixelTerminalBorderSkinId, ProviderId, ProviderSecretId, SessionBounds @@ -18,6 +23,8 @@ import { IPC, PROVIDER_SECRET_IDS, isProviderId } from "../../shared/contracts"; import { isCanvasNavigationMouseButton } from "../../shared/canvasNavigation"; import { createWindowStateObserver, readWindowState } from "../windowState"; import type { SettingsStore } from "../services/SettingsStore"; +import { isCustomTerminalBorderSkinId, type SkinRegistry } from "../services/SkinRegistry"; +import { isPixelSkinSlot, isPixelTerminalBorderSkinId, type PixelSkinPackRegistry } from "../services/PixelSkinPackRegistry"; import { providerCliAvailability, type ProviderCliRegistry } from "../services/providerCliRegistry"; import type { TerminalManager } from "../services/TerminalManager"; import type { LimitsService } from "../services/LimitsService"; @@ -37,6 +44,8 @@ import { readHomeMedia } from "../services/homeMedia"; interface Dependencies { settings: SettingsStore; + terminalBorderSkins: SkinRegistry; + pixelSkinPacks: PixelSkinPackRegistry; providerClis: ProviderCliRegistry; recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>; terminals: TerminalManager; @@ -72,6 +81,8 @@ interface Dependencies { export function registerIpc({ settings, + terminalBorderSkins, + pixelSkinPacks, providerClis, recheckProviderClis, terminals, @@ -124,6 +135,40 @@ export function registerIpc({ return app.getVersion(); }); ipcMain.handle(IPC.settingsGet, () => settings.get()); + ipcMain.handle(IPC.terminalBorderSkinsList, (event) => { + assertMainRenderer(event, getMainWindow); + return terminalBorderSkins.list(); + }); + ipcMain.handle(IPC.terminalBorderSkinsGet, (event, id: unknown) => { + assertMainRenderer(event, getMainWindow); + if (!isCustomTerminalBorderSkinId(id)) throw new Error("Custom terminal skin ID is invalid."); + return terminalBorderSkins.get(id as CustomTerminalBorderSkinId); + }); + terminalBorderSkins.onChanged(() => { + const window = getMainWindow(); + if (window && !window.isDestroyed()) window.webContents.send(IPC.terminalBorderSkinsChanged); + }); + ipcMain.handle(IPC.pixelSkinsList, (event) => { + assertMainRenderer(event, getMainWindow); + return pixelSkinPacks.list(); + }); + ipcMain.handle(IPC.pixelSkinsInstall, (event, request: PixelSkinPackInstallRequest) => { + assertMainRenderer(event, getMainWindow); + return pixelSkinPacks.install(request); + }); + ipcMain.handle(IPC.pixelSkinsInstallZip, (event, request: PixelSkinZipInstallRequest) => { + assertMainRenderer(event, getMainWindow); + return pixelSkinPacks.installZip(request.archive, request.name, request.apertures); + }); + ipcMain.handle(IPC.pixelSkinsReadAsset, (event, id: unknown, slot: unknown) => { + assertMainRenderer(event, getMainWindow); + if (!isPixelTerminalBorderSkinId(id) || !isPixelSkinSlot(slot)) return null; + return pixelSkinPacks.readAsset(id as PixelTerminalBorderSkinId, slot as PixelSkinSlot); + }); + pixelSkinPacks.onChanged(() => { + const window = getMainWindow(); + if (window && !window.isDestroyed()) window.webContents.send(IPC.pixelSkinsChanged); + }); ipcMain.handle(IPC.agentsAvailability, (event) => { assertMainRenderer(event, getMainWindow); return providerCliAvailability(providerClis); diff --git a/src/main/services/PixelSkinPackRegistry.ts b/src/main/services/PixelSkinPackRegistry.ts new file mode 100644 index 00000000..c3dc76ef --- /dev/null +++ b/src/main/services/PixelSkinPackRegistry.ts @@ -0,0 +1,296 @@ +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DEFAULT_PIXEL_SKIN_APERTURES } from "../../shared/contracts.ts"; +import type { + PixelSkinAperture, + PixelSkinApertures, + PixelSkinPackInstallRequest, + PixelSkinPackSummary, + PixelSkinSlot, + PixelTerminalBorderSkinId +} from "../../shared/contracts"; + +export const PIXEL_SKIN_SLOTS = [ + "minimal_idle", "minimal_working", "minimal_completed", + "detailed_idle", "detailed_working", "detailed_completed", + "master_idle", "master_working", "master_completed", "background" +] as const satisfies readonly PixelSkinSlot[]; + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +export const MAX_PIXEL_SKIN_ARCHIVE_BYTES = 150 * 1024 * 1024; +const MAX_PACK_BYTES = MAX_PIXEL_SKIN_ARCHIVE_BYTES; +const MAX_PACKS = 100; +const MAX_ARCHIVE_ENTRIES = 100; +const ID_PATTERN = /^pixel:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + return value >>> 0; +}); +const require = createRequire(import.meta.url); +const unzipper = require("unzipper") as { + Open: { buffer(data: Buffer): Promise<{ files: Array<{ + path: string; + type: string; + uncompressedSize: number; + stream(): AsyncIterable; + }> }> }; +}; + +function pngChunkCrc(bytes: Buffer, start: number, end: number): number { + let crc = 0xffffffff; + for (let offset = start; offset < end; offset += 1) crc = CRC_TABLE[(crc ^ bytes[offset]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +interface PackManifest { + schemaVersion: 1; + id: PixelTerminalBorderSkinId; + name: string; + width: number; + height: number; + aperture: PixelSkinAperture; + apertures: PixelSkinApertures; +} + +const DEFAULT_APERTURE: PixelSkinAperture = { left: 8, right: 8, top: 17, bottom: 13 }; + +function validApertures(value: unknown, fallback: PixelSkinAperture): PixelSkinApertures { + if (value === undefined) return { minimal: fallback, detailed: fallback, master: fallback }; + if (!value || typeof value !== "object") throw new Error("Terminal apertures are invalid."); + const apertures = value as Record; + if (Object.keys(apertures).length !== 3 || ["minimal", "detailed", "master"].some((level) => !(level in apertures))) { + throw new Error("Terminal apertures are invalid."); + } + return { + minimal: validAperture(apertures.minimal), + detailed: validAperture(apertures.detailed), + master: validAperture(apertures.master) + }; +} + +function validAperture(value: unknown): PixelSkinAperture { + if (value === undefined) return DEFAULT_APERTURE; + if (!value || typeof value !== "object") throw new Error("Terminal aperture is invalid."); + const aperture = value as Record; + for (const edge of ["left", "right", "top", "bottom"] as const) { + if (typeof aperture[edge] !== "number" || !Number.isFinite(aperture[edge]) + || aperture[edge] < 2 || aperture[edge] > 35) throw new Error("Terminal aperture is invalid."); + } + if ((aperture.left as number) + (aperture.right as number) > 55 + || (aperture.top as number) + (aperture.bottom as number) > 55) { + throw new Error("Terminal aperture leaves too little room for output."); + } + return aperture as unknown as PixelSkinAperture; +} + +export function isPixelTerminalBorderSkinId(value: unknown): value is PixelTerminalBorderSkinId { + return typeof value === "string" && ID_PATTERN.test(value); +} + +export function isPixelSkinSlot(value: unknown): value is PixelSkinSlot { + return typeof value === "string" && (PIXEL_SKIN_SLOTS as readonly string[]).includes(value); +} + +function parsePng(data: Uint8Array): { width: number; height: number } { + if (!(data instanceof Uint8Array) || data.byteLength < 57 || data.byteLength > MAX_IMAGE_BYTES) { + throw new Error("PNG file size is invalid."); + } + const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("Only PNG images are accepted."); + let offset = 8; + let width = 0; + let height = 0; + let hasImageData = false; + let ended = false; + while (offset + 12 <= bytes.length) { + const length = bytes.readUInt32BE(offset); + const end = offset + 12 + length; + if (length > MAX_IMAGE_BYTES || end > bytes.length) throw new Error("PNG chunks are malformed."); + if (pngChunkCrc(bytes, offset + 4, offset + 8 + length) !== bytes.readUInt32BE(offset + 8 + length)) { + throw new Error("PNG checksum is invalid."); + } + const kind = bytes.toString("ascii", offset + 4, offset + 8); + if (offset === 8) { + if (kind !== "IHDR" || length !== 13) throw new Error("PNG header is malformed."); + width = bytes.readUInt32BE(offset + 8); + height = bytes.readUInt32BE(offset + 12); + if (width < 320 || height < 200 || width > 4096 || height > 4096) { + throw new Error("PNG dimensions must be between 320x200 and 4096x4096."); + } + } + if (kind === "IDAT") hasImageData = true; + if (kind === "IEND") { + if (length !== 0 || end !== bytes.length) throw new Error("PNG ending is malformed."); + ended = true; + break; + } + offset = end; + } + if (!hasImageData || !ended) throw new Error("PNG image data is incomplete."); + return { width, height }; +} + +function validName(value: unknown): string { + if (typeof value !== "string") throw new Error("Theme name is required."); + const name = value.trim(); + if (!name || name.length > 64 || /[\x00-\x1f\x7f]/.test(name)) throw new Error("Theme name is invalid."); + return name; +} + +export class PixelSkinPackRegistry { + private readonly root: string; + private readonly packs = new Map(); + private readonly listeners = new Set<() => void>(); + + constructor(userDataPath: string) { + this.root = join(userDataPath, "pixel-skins"); + } + + async initialize(): Promise { + await mkdir(this.root, { recursive: true }); + const entries = await readdir(this.root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const id = `pixel:${entry.name}`; + if (!isPixelTerminalBorderSkinId(id)) continue; + try { + const path = join(this.root, entry.name); + const manifestPath = join(path, "manifest.json"); + if (!(await lstat(manifestPath)).isFile()) continue; + const raw = await readFile(manifestPath); + if (raw.length > 4096) continue; + const manifest = JSON.parse(raw.toString("utf8")) as Partial; + if (manifest.schemaVersion !== 1 || manifest.id !== id) continue; + const name = validName(manifest.name); + const aperture = validAperture(manifest.aperture); + const apertures = validApertures(manifest.apertures, aperture); + if (!Number.isInteger(manifest.width) || !Number.isInteger(manifest.height)) continue; + const files = await Promise.all(PIXEL_SKIN_SLOTS.map(async (slot) => { + const stat = await lstat(join(path, `${slot}.png`)); + return stat.isFile() && !stat.isSymbolicLink() && stat.size <= MAX_IMAGE_BYTES; + })); + if (files.every(Boolean)) this.packs.set(id, { + schemaVersion: 1, id, name, aperture, apertures, + width: manifest.width as number, height: manifest.height as number + }); + } catch { + // A malformed user pack is unavailable; it must not prevent app startup. + } + } + } + + list(): PixelSkinPackSummary[] { + return [...this.packs.values()].map(({ id, name, aperture, apertures }) => ({ id, name, aperture, apertures })); + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async installZip(archive: Uint8Array, name: string, apertures?: PixelSkinApertures): Promise { + if (!(archive instanceof Uint8Array) || archive.byteLength < 22 || archive.byteLength > MAX_PACK_BYTES) { + throw new Error("Theme ZIP file size is invalid."); + } + const directory = await unzipper.Open.buffer(Buffer.from(archive.buffer, archive.byteOffset, archive.byteLength)); + if (directory.files.length > MAX_ARCHIVE_ENTRIES) throw new Error("Theme ZIP has too many entries."); + const entries = new Map(); + for (const entry of directory.files) { + if (entry.type !== "File") continue; + const filename = entry.path.replace(/\\/g, "/").split("/").at(-1) ?? ""; + if (!filename.endsWith(".png")) continue; + const slot = filename.slice(0, -4); + if (!isPixelSkinSlot(slot)) continue; + if (entries.has(slot)) throw new Error(`Theme ZIP contains duplicate ${filename}.`); + if (entry.uncompressedSize < 1 || entry.uncompressedSize > MAX_IMAGE_BYTES) { + throw new Error(`${filename} exceeds the PNG size limit.`); + } + entries.set(slot, entry); + } + if (entries.size !== PIXEL_SKIN_SLOTS.length) { + throw new Error("Theme ZIP needs minimal/detailed/master idle, working, completed PNGs and background.png."); + } + const files = {} as Record; + let total = 0; + for (const slot of PIXEL_SKIN_SLOTS) { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of entries.get(slot)!.stream()) { + size += chunk.length; + if (size > MAX_IMAGE_BYTES || total + size > MAX_PACK_BYTES) throw new Error(`${slot}.png exceeds the PNG size limit.`); + chunks.push(chunk); + } + total += size; + files[slot] = Buffer.concat(chunks, size); + } + return this.install({ name, apertures, files }); + } + + async install(request: PixelSkinPackInstallRequest): Promise { + if (!request || typeof request !== "object" || !request.files || typeof request.files !== "object") { + throw new Error("Theme package is invalid."); + } + if (this.packs.size >= MAX_PACKS) throw new Error("Too many pixel themes are installed."); + const name = validName(request.name); + const aperture = validAperture(request.aperture); + const apertures = request.apertures + ? validApertures(request.apertures, aperture) + : request.aperture + ? validApertures(undefined, aperture) + : validApertures(DEFAULT_PIXEL_SKIN_APERTURES, aperture); + const files = request.files as Record; + if (Object.keys(files).length !== PIXEL_SKIN_SLOTS.length + || Object.keys(files).some((slot) => !isPixelSkinSlot(slot))) { + throw new Error("A pixel theme needs exactly nine terminal PNGs and one background PNG."); + } + let width = 0; + let height = 0; + let total = 0; + for (const slot of PIXEL_SKIN_SLOTS) { + const data = files[slot]; + if (!(data instanceof Uint8Array)) throw new Error(`${slot} must be a PNG image.`); + total += data.byteLength; + if (total > MAX_PACK_BYTES) throw new Error("Theme package is too large."); + const dimensions = parsePng(data); + if (slot !== "background") { + if (!width) ({ width, height } = dimensions); + else if (width !== dimensions.width || height !== dimensions.height) { + throw new Error("All nine terminal frames must have the same dimensions."); + } + } + } + const id = `pixel:${randomUUID()}` as PixelTerminalBorderSkinId; + const manifest: PackManifest = { schemaVersion: 1, id, name, width, height, aperture, apertures }; + await mkdir(this.root, { recursive: true }); + const temporary = await mkdtemp(join(this.root, ".install-")); + try { + for (const slot of PIXEL_SKIN_SLOTS) { + await writeFile(join(temporary, `${slot}.png`), files[slot] as Uint8Array, { flag: "wx" }); + } + await writeFile(join(temporary, "manifest.json"), JSON.stringify(manifest), { flag: "wx" }); + await rename(temporary, join(this.root, id.slice("pixel:".length))); + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + throw error; + } + this.packs.set(id, manifest); + for (const listener of this.listeners) listener(); + return { id, name, aperture, apertures }; + } + + async readAsset(id: PixelTerminalBorderSkinId, slot: PixelSkinSlot): Promise { + if (!isPixelTerminalBorderSkinId(id) || !isPixelSkinSlot(slot) || !this.packs.has(id)) return null; + const path = join(this.root, id.slice("pixel:".length), `${slot}.png`); + try { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_IMAGE_BYTES) return null; + return await readFile(path); + } catch { + return null; + } + } +} diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 38947bdf..d55dbb2e 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -8,6 +8,7 @@ import type { ApiProfile, ApiProfileProtocol, AppSettings, + AppSkinId, BrowserCanvasState, CanvasLauncherItemId, CanvasRegion, @@ -27,12 +28,14 @@ import type { MinimapInteractionMode, PaletteId, PluginCanvasInstance, + PixelSkinPreferredDetail, ProviderSecretId, RadialLauncherItemId, SessionRestoreMode, SessionRowColorMode, ShortcutBindings, StickyNote, + TerminalBorderSkinId, ZoomSensitivity } from "../../shared/contracts"; import { @@ -57,6 +60,7 @@ import { UI_SCALE_MIN, UI_SCALE_STEP } from "../../shared/contracts.ts"; +import { isTerminalBorderSkinId } from "./SkinRegistry.ts"; import { canvasNavigationPlatform, defaultCanvasWheelBinding, @@ -72,6 +76,7 @@ const HOME_ACCENT_PRESETS = new Set(["classic", "warm", "coo const SESSION_ROW_COLOR_MODES = new Set(["monochrome", "status"]); const CANVAS_COLORS = new Set(["sage", "lilac", "night", "sand", "mist", "rose", "slate"]); const PATTERNS = new Set(["dots", "grid", "waves", "diagonal", "rings", "none"]); +const APP_SKINS = new Set(["classic", "atelier", "signal", "greenhouse", "midnight"]); const MEDIA_FITS = new Set(["cover", "contain"]); // 21: the on/off "restoreTerminalSessions" became sessionRestoreMode (off / reopen / continue). const SETTINGS_VERSION = 21; @@ -351,6 +356,10 @@ function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", pattern: "dots", + terminalBorderSkin: "classic", + terminalSkinDetail: "detailed", + terminalSkinAnimationEnabled: true, + appSkin: "classic", snapToGrid: true, invertTerminalWheel: true, invertCanvasWheel: false, @@ -541,6 +550,18 @@ export function normalizeSettings( pattern: PATTERNS.has(source.pattern as CanvasPatternId) ? source.pattern as CanvasPatternId : fallback.pattern, + terminalBorderSkin: isTerminalBorderSkinId(source.terminalBorderSkin) + ? source.terminalBorderSkin as TerminalBorderSkinId + : fallback.terminalBorderSkin, + terminalSkinDetail: source.terminalSkinDetail === "minimal" || source.terminalSkinDetail === "detailed" + ? source.terminalSkinDetail as PixelSkinPreferredDetail + : fallback.terminalSkinDetail, + terminalSkinAnimationEnabled: typeof source.terminalSkinAnimationEnabled === "boolean" + ? source.terminalSkinAnimationEnabled + : fallback.terminalSkinAnimationEnabled, + appSkin: APP_SKINS.has(source.appSkin as AppSkinId) + ? source.appSkin as AppSkinId + : fallback.appSkin, snapToGrid: typeof source.snapToGrid === "boolean" ? source.snapToGrid : fallback.snapToGrid, invertTerminalWheel: typeof source.invertTerminalWheel === "boolean" ? source.invertTerminalWheel diff --git a/src/main/services/SkinRegistry.ts b/src/main/services/SkinRegistry.ts new file mode 100644 index 00000000..c7a4eb6a --- /dev/null +++ b/src/main/services/SkinRegistry.ts @@ -0,0 +1,707 @@ +import { createHash } from "node:crypto"; +import { watch as watchFileSystem, type Dirent, type FSWatcher } from "node:fs"; +import { constants as fsConstants } from "node:fs"; +import { lstat, mkdir, open, readdir, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { + CustomTerminalBorderSkinId, + TerminalBorderSkinListItem, + TerminalBorderSkinManifest, + TerminalBorderSkinReadResult +} from "../../shared/contracts"; + +const MAX_SKINS = 100; +const MAX_MANIFEST_BYTES = 4 * 1024; +const MAX_CSS_BYTES = 64 * 1024; +const MAX_SKIN_NAME_LENGTH = 64; +const MAX_SKIN_SLUG_LENGTH = 48; +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CUSTOM_SKIN_ID_PATTERN = /^custom:([a-z0-9]+(?:-[a-z0-9]+)*)$/; +const BORDER_PROPERTIES = /^(?:border(?:-(?:top|right|bottom|left))?(?:-(?:color|style|width))?|border-radius|border-image(?:-(?:source|slice|width|outset|repeat))?|outline(?:-(?:color|style|width|offset))?|box-shadow|background(?:-(?:color|image|position|size|repeat|origin|clip))?|color|opacity)$/; +const DANGEROUS_CSS = /(?:@import|\burl\s*\(|\bexpression\s*\(|\b(?:javascript|vbscript|file|data)\s*:|(?:https?:)?\/\/|-moz-binding|(?:^|[;{\s])behavior\s*:|@namespace|@font-face|@document|@supports|@media|paint\s*\(|element\s*\()/i; + +interface LoadedSkin { + id: CustomTerminalBorderSkinId; + name: string; + revision: string; + css: string; +} + +interface SkinState { + good?: LoadedSkin; + error?: string; +} + +interface WatchedDirectory { + watcher: FSWatcher; + dev: number; + ino: number; +} + +interface RootWatcherHandle { + close(): void; +} + +type RootWatcherFactory = ( + path: string, + onChange: () => void, + onError: (error?: unknown) => void +) => RootWatcherHandle; + +export interface SkinRegistryOptions { + /** Injectable so watcher install and runtime failures can be tested deterministically. */ + rootWatcherFactory?: RootWatcherFactory; + /** Retry timing is configurable for focused tests; production values are bounded below. */ + rootRetryBaseMs?: number; + rootRetryMaxMs?: number; +} + +const DEFAULT_ROOT_RETRY_BASE_MS = 250; +const DEFAULT_ROOT_RETRY_MAX_MS = 30_000; +const MAX_ROOT_RETRY_EXPONENT = 16; + +const nativeRootWatcherFactory: RootWatcherFactory = (path, onChange, onError) => { + const watcher = watchFileSystem(path, () => onChange()); + watcher.on("error", onError); + return watcher; +}; + +/** The slug remains deliberately narrower than the shared template-string type. */ +export function isCustomTerminalBorderSkinId(value: unknown): value is CustomTerminalBorderSkinId { + if (typeof value !== "string") return false; + const match = CUSTOM_SKIN_ID_PATTERN.exec(value); + return Boolean(match && match[1].length <= MAX_SKIN_SLUG_LENGTH); +} + +function isSkinSlug(value: string): boolean { + return value.length <= MAX_SKIN_SLUG_LENGTH && SLUG_PATTERN.test(value); +} + +export function isTerminalBorderSkinId(value: unknown): value is import("../../shared/contracts").TerminalBorderSkinId { + return value === "classic" + || value === "minimal" + || value === "glass" + || value === "cyber" + || value === "nord" + || value === "gradient" + || value === "cybercore" + || value === "titanium" + || value === "retro" + || value === "sakura" + || value === "matrix" + || value === "forest-cabin" + || value === "gold-black" + || value === "cat" + || value === "gothic-eclipse" + || (typeof value === "string" && /^pixel:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) + || isCustomTerminalBorderSkinId(value); +} + +/** + * Loads user-authored, local-only terminal border skins from userData/skins. + * CSS is a restricted stylesheet; it is never evaluated as JavaScript. + */ +export class SkinRegistry { + private readonly requestedRoot: string; + private root = ""; + private readonly entries = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly directoryWatchers = new Map(); + private readonly rootWatcherFactory: RootWatcherFactory; + private readonly rootRetryBaseMs: number; + private readonly rootRetryMaxMs: number; + private rootWatcher?: RootWatcherHandle; + private rootRetryTimer?: ReturnType; + private rootRetryAttempt = 0; + private debounceTimer?: ReturnType; + private scanInProgress = false; + private scanAgain = false; + private initialized = false; + private disposed = false; + private rootError?: string; + + constructor(userDataPath: string, options: SkinRegistryOptions = {}) { + this.requestedRoot = join(userDataPath, "skins"); + this.rootWatcherFactory = options.rootWatcherFactory ?? nativeRootWatcherFactory; + this.rootRetryBaseMs = boundedRetryDelay(options.rootRetryBaseMs, DEFAULT_ROOT_RETRY_BASE_MS); + this.rootRetryMaxMs = Math.max( + this.rootRetryBaseMs, + boundedRetryDelay(options.rootRetryMaxMs, DEFAULT_ROOT_RETRY_MAX_MS) + ); + } + + async initialize(): Promise { + if (this.initialized) return; + try { + await mkdir(this.requestedRoot, { recursive: true }); + const rootStat = await lstat(this.requestedRoot); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error("The terminal skin directory must be a real directory."); + } + this.root = await realpath(this.requestedRoot); + } catch (error) { + this.rootError = safeErrorMessage(error); + this.initialized = true; + return; + } + this.installRootWatcher(); + try { + await this.scan(false); + } catch (error) { + this.rootError = safeErrorMessage(error); + this.entries.clear(); + } + this.initialized = true; + } + + list(): TerminalBorderSkinListItem[] { + if (this.rootError) { + return [{ + id: "custom:skin-registry", + status: "error", + error: this.rootError + }]; + } + return [...this.entries.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([slug, state]) => { + if (state.error) { + return { + id: `custom:${slug}` as CustomTerminalBorderSkinId, + ...(state.good ? { name: state.good.name, revision: state.good.revision } : {}), + status: "error" as const, + error: state.error + }; + } + if (!state.good) return undefined; + return { + id: state.good.id, + name: state.good.name, + revision: state.good.revision, + status: "ready" as const + }; + }) + .filter((item): item is TerminalBorderSkinListItem => Boolean(item)); + } + + get(id: CustomTerminalBorderSkinId): TerminalBorderSkinReadResult { + if (!isCustomTerminalBorderSkinId(id)) { + throw new Error("Custom terminal skin ID is invalid."); + } + if (this.rootError) return { id, status: "error", error: this.rootError }; + const slug = id.slice("custom:".length); + const state = this.entries.get(slug); + if (state?.good) { + // Keep serving the last valid CSS during a malformed edit. list() carries + // the current error so the renderer can show that a newer edit was rejected. + return { ...state.good, status: "ready" }; + } + return { + id, + status: "error", + error: state?.error ?? "Terminal border skin was not found." + }; + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + dispose(): void { + this.disposed = true; + if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.rootRetryTimer) clearTimeout(this.rootRetryTimer); + this.rootRetryTimer = undefined; + this.rootWatcher?.close(); + this.rootWatcher = undefined; + for (const watched of this.directoryWatchers.values()) watched.watcher.close(); + this.directoryWatchers.clear(); + this.listeners.clear(); + } + + private installRootWatcher(): void { + if (this.disposed || !this.root || this.rootWatcher) return; + let watcher: RootWatcherHandle | undefined; + let failedBeforeAssignment = false; + try { + watcher = this.rootWatcherFactory( + this.root, + () => { + if (this.disposed || this.rootWatcher !== watcher) return; + this.rootRetryAttempt = 0; + this.scheduleScan(); + }, + () => { + if (!watcher) { + failedBeforeAssignment = true; + return; + } + this.handleRootWatcherFailure(watcher); + } + ); + if (this.disposed || failedBeforeAssignment) { + watcher.close(); + if (!this.disposed) this.scheduleRootWatcherRetry(); + return; + } + this.rootWatcher = watcher; + } catch { + this.rootWatcher = undefined; + this.scheduleRootWatcherRetry(); + } + } + + private handleRootWatcherFailure(watcher: RootWatcherHandle): void { + if (this.disposed || this.rootWatcher !== watcher) return; + this.rootWatcher = undefined; + watcher.close(); + this.scheduleScan(); + this.scheduleRootWatcherRetry(); + } + + private scheduleRootWatcherRetry(): void { + if (this.disposed || !this.root || this.rootRetryTimer) return; + const exponent = Math.min(this.rootRetryAttempt, MAX_ROOT_RETRY_EXPONENT); + const delay = Math.min(this.rootRetryBaseMs * (2 ** exponent), this.rootRetryMaxMs); + this.rootRetryAttempt = Math.min(this.rootRetryAttempt + 1, MAX_ROOT_RETRY_EXPONENT); + this.rootRetryTimer = setTimeout(() => { + this.rootRetryTimer = undefined; + if (this.disposed) return; + this.installRootWatcher(); + // Even if the watcher cannot be reinstalled, bounded retries continue + // to discover folder additions and removals without requiring restart. + void this.scan(true).catch(() => undefined); + }, delay); + } + + private scheduleScan(): void { + if (this.disposed || (!this.initialized && !this.root)) return; + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + void this.scan(true).catch(() => undefined); + }, 80); + } + + private async scan(notify: boolean): Promise { + if (this.scanInProgress) { + this.scanAgain = true; + return; + } + this.scanInProgress = true; + try { + do { + this.scanAgain = false; + await this.scanOnce(notify); + } while (this.scanAgain); + } finally { + this.scanInProgress = false; + if (this.scanAgain) { + this.scanAgain = false; + this.scheduleScan(); + } + } + } + + private async scanOnce(notify: boolean): Promise { + const children = await readdir(this.root, { withFileTypes: true }); + this.rootError = undefined; + const slugs = children + .map((entry) => entry.name) + .filter(isSkinSlug) + .sort(); + await this.refreshDirectoryWatchers(children); + + const previous = new Map(this.entries); + const next = new Map(); + for (const [index, slug] of slugs.entries()) { + if (index >= MAX_SKINS) { + next.set(slug, { error: `Only the first ${MAX_SKINS} terminal skins can be loaded.` }); + continue; + } + const oldState = this.entries.get(slug); + try { + next.set(slug, { good: await this.loadSkin(slug) }); + } catch (error) { + next.set(slug, { + ...(oldState?.good ? { good: oldState.good } : {}), + error: safeErrorMessage(error) + }); + } + } + const changed = !sameEntries(previous, next); + this.entries.clear(); + for (const [slug, state] of next) this.entries.set(slug, state); + + if (notify && changed && this.listeners.size > 0) { + for (const listener of this.listeners) listener(); + } + } + + private async refreshDirectoryWatchers(children: Dirent[]): Promise { + const available = new Set(children + .filter((child) => isSkinSlug(child.name) && child.isDirectory()) + .map((child) => child.name) + .sort() + .slice(0, MAX_SKINS)); + for (const slug of available) { + try { + const directory = resolve(this.root, slug); + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory) continue; + const existing = this.directoryWatchers.get(slug); + if (existing?.dev === directoryStat.dev && existing.ino === directoryStat.ino) continue; + existing?.watcher.close(); + this.directoryWatchers.delete(slug); + const watcher = watchFileSystem(directory, () => this.scheduleScan()); + watcher.on("error", () => { + watcher.close(); + if (this.directoryWatchers.get(slug)?.watcher === watcher) this.directoryWatchers.delete(slug); + this.scheduleScan(); + }); + this.directoryWatchers.set(slug, { watcher, dev: directoryStat.dev, ino: directoryStat.ino }); + } catch { + // Invalid or disappearing folders will be surfaced by the scanner. + } + } + for (const [slug, watched] of this.directoryWatchers) { + if (available.has(slug)) continue; + watched.watcher.close(); + this.directoryWatchers.delete(slug); + } + } + + private async loadSkin(slug: string): Promise { + if (!isSkinSlug(slug)) throw new Error("Skin folder name is invalid."); + const directory = resolve(this.root, slug); + const directoryRelative = relative(this.root, directory); + if (!directoryRelative || directoryRelative.startsWith(`..${sep}`) || directoryRelative === ".." || isAbsolute(directoryRelative)) { + throw new Error("Skin path escapes the skin directory."); + } + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error("Skin folder must be a real directory, not a symlink."); + } + if (await realpath(directory) !== directory) { + throw new Error("Skin folder resolves outside the skin directory."); + } + + const manifestPath = join(directory, "manifest.json"); + const manifestText = await readBoundedUtf8File(manifestPath, MAX_MANIFEST_BYTES, "Skin manifest"); + const manifest = parseManifest(manifestText, slug); + const cssPath = join(directory, "skin.css"); + const css = await readBoundedUtf8File(cssPath, MAX_CSS_BYTES, "Skin CSS"); + validateSkinCss(css); + + const id = `custom:${slug}` as CustomTerminalBorderSkinId; + const revision = createHash("sha256").update(manifest.name).update("\0").update(css).digest("hex").slice(0, 16); + return { id, name: manifest.name, revision, css }; + } +} + +function boundedRetryDelay(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.min(DEFAULT_ROOT_RETRY_MAX_MS, Math.max(10, Math.floor(value))); +} + +async function readBoundedUtf8File(path: string, maxBytes: number, label: string): Promise { + const before = await lstat(path); + if (before.isSymbolicLink() || !before.isFile()) throw new Error(`${label} must be a regular file, not a symlink.`); + if (before.size > maxBytes) throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + + let flags = fsConstants.O_RDONLY; + if (typeof fsConstants.O_NOFOLLOW === "number") flags |= fsConstants.O_NOFOLLOW; + const handle = await open(path, flags); + try { + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) { + throw new Error(`${label} changed while it was being opened.`); + } + if (opened.size > maxBytes) throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + + const buffer = Buffer.alloc(maxBytes + 1); + let total = 0; + while (total <= maxBytes) { + const { bytesRead } = await handle.read(buffer, total, Math.min(4096, buffer.length - total), null); + if (bytesRead === 0) break; + total += bytesRead; + } + if (total > maxBytes) throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total)); + } catch { + throw new Error(`${label} must use valid UTF-8.`); + } + } finally { + await handle.close(); + } +} + +function parseManifest(text: string, slug: string): TerminalBorderSkinManifest { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Skin manifest is not valid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Skin manifest must be a JSON object."); + } + const value = parsed as Record; + const keys = Object.keys(value).sort(); + if ( + keys.length !== 4 + || keys.join(",") !== "id,kind,name,schemaVersion" + || value.schemaVersion !== 1 + || value.kind !== "terminal-border" + || value.id !== slug + || typeof value.name !== "string" + || value.name.trim().length === 0 + || value.name.trim().length > MAX_SKIN_NAME_LENGTH + || /[\u0000-\u001f\u007f]/.test(value.name) + ) { + throw new Error("Skin manifest fields are invalid or do not match the folder name."); + } + return { + schemaVersion: 1, + id: slug, + name: value.name.trim(), + kind: "terminal-border" + }; +} + +function validateSkinCss(css: string): void { + if (Buffer.byteLength(css, "utf8") === 0) throw new Error("Skin CSS is empty."); + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(css)) { + throw new Error("Skin CSS contains unsupported control characters."); + } + if (css.includes("<")) throw new Error("Skin CSS cannot contain HTML markup."); + if (css.includes("\\")) throw new Error("Skin CSS escapes are not supported."); + + const normalized = stripCssComments(css); + if (DANGEROUS_CSS.test(normalized)) throw new Error("Skin CSS contains a forbidden import, URL, or executable CSS feature."); + if (normalized.trim().length === 0) throw new Error("Skin CSS is empty."); + + let cursor = 0; + let ruleCount = 0; + while (cursor < normalized.length) { + while (/\s/.test(normalized[cursor] ?? "")) cursor += 1; + if (cursor >= normalized.length) break; + + const openBrace = findCssDelimiter(normalized, cursor, "{"); + if (openBrace < 0) throw new Error("Skin CSS contains an incomplete rule."); + const selectorText = normalized.slice(cursor, openBrace).trim(); + if (!selectorText || selectorText.startsWith("@") || selectorText.includes("@") || selectorText.includes(";")) { + throw new Error("Skin CSS at-rules and malformed selectors are not supported."); + } + for (const selector of splitTopLevel(selectorText, ",")) { + validateScopedSelector(selector); + } + + const closeBrace = findCssBlockEnd(normalized, openBrace + 1); + const body = normalized.slice(openBrace + 1, closeBrace); + validateDeclarations(body); + ruleCount += 1; + if (ruleCount > 256) throw new Error("Skin CSS contains too many rules."); + cursor = closeBrace + 1; + } + if (ruleCount === 0) throw new Error("Skin CSS does not contain any rules."); +} + +function stripCssComments(value: string): string { + let result = ""; + let quote = ""; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + result += character; + if (character === quote && value[index - 1] !== "\\") quote = ""; + continue; + } + if (character === "'" || character === '"') { + quote = character; + result += character; + continue; + } + if (character === "/" && value[index + 1] === "*") { + const end = value.indexOf("*/", index + 2); + if (end < 0) throw new Error("Skin CSS contains an unclosed comment."); + index = end + 1; + continue; + } + result += character; + } + if (quote) throw new Error("Skin CSS contains an unclosed string."); + return result; +} + +function findCssDelimiter(value: string, start: number, delimiter: string): number { + let quote = ""; + let parentheses = 0; + let brackets = 0; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === delimiter && parentheses === 0 && brackets === 0) return index; + else if (character === "}" && parentheses === 0 && brackets === 0) { + throw new Error("Skin CSS contains an unexpected closing brace."); + } + if (parentheses < 0 || brackets < 0) throw new Error("Skin CSS contains unbalanced delimiters."); + } + if (quote || parentheses !== 0 || brackets !== 0) throw new Error("Skin CSS contains unbalanced delimiters."); + return -1; +} + +function findCssBlockEnd(value: string, start: number): number { + let quote = ""; + let parentheses = 0; + let brackets = 0; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === "{") throw new Error("Nested CSS rules are not supported."); + else if (character === "}" && parentheses === 0 && brackets === 0) return index; + if (parentheses < 0 || brackets < 0) throw new Error("Skin CSS contains unbalanced delimiters."); + } + throw new Error("Skin CSS contains an unclosed rule."); +} + +function splitTopLevel(value: string, delimiter: string): string[] { + const result: string[] = []; + let start = 0; + let quote = ""; + let parentheses = 0; + let brackets = 0; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === delimiter && parentheses === 0 && brackets === 0) { + result.push(value.slice(start, index)); + start = index + 1; + } + } + result.push(value.slice(start)); + return result; +} + +function validateScopedSelector(selector: string): void { + const normalized = selector.trim(); + if (!normalized.startsWith(".terminal-card")) { + throw new Error("Every skin selector must start inside .terminal-card."); + } + const next = normalized[".terminal-card".length]; + if (next && !/[.#:[\s>+~]/.test(next)) { + throw new Error("Skin selector does not target the .terminal-card scope."); + } + if (/:global\b|:host\b|::slotted\b|::part\b|::backdrop\b|::view-transition\b/i.test(normalized)) { + throw new Error("Skin selector cannot escape the .terminal-card scope."); + } + if (hasTopLevelColumnCombinator(normalized)) { + throw new Error("Skin selector cannot use a column combinator outside the .terminal-card scope."); + } + if (hasTopLevelSiblingCombinator(normalized)) { + throw new Error("Skin selector cannot use sibling combinators outside the .terminal-card scope."); + } +} + +function hasTopLevelColumnCombinator(selector: string): boolean { + let parentheses = 0; + let brackets = 0; + let quote = ""; + for (let index = 0; index < selector.length - 1; index += 1) { + const character = selector[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === "|" && selector[index + 1] === "|" && parentheses === 0 && brackets === 0) return true; + } + return false; +} + +function hasTopLevelSiblingCombinator(selector: string): boolean { + let parentheses = 0; + let brackets = 0; + let quote = ""; + for (const character of selector) { + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (parentheses === 0 && brackets === 0) { + if (character === "+" || character === "~") return true; + } + } + return false; +} + +function validateDeclarations(body: string): void { + const declarations = splitTopLevel(body, ";"); + let count = 0; + for (const declaration of declarations) { + const trimmed = declaration.trim(); + if (!trimmed) continue; + const colon = trimmed.indexOf(":"); + if (colon <= 0) throw new Error("Skin CSS contains an invalid declaration."); + const property = trimmed.slice(0, colon).trim().toLowerCase(); + const value = trimmed.slice(colon + 1).trim(); + if (!/^(?:--[a-z0-9_-]+|[a-z][a-z0-9-]*)$/.test(property) || !value) { + throw new Error("Skin CSS contains an invalid declaration."); + } + if (!property.startsWith("--") && !BORDER_PROPERTIES.test(property)) { + throw new Error(`Skin CSS property '${property}' is not allowed for terminal border skins.`); + } + if (/!\s*important/i.test(value)) throw new Error("Skin CSS cannot use !important."); + count += 1; + } + if (count === 0) throw new Error("Skin CSS rules must contain at least one declaration."); +} + +function safeErrorMessage(error: unknown): string { + if (error instanceof Error && !("code" in error) && error.message.length <= 240) return error.message; + return "Skin files could not be read or validated."; +} + +function sameEntries(left: Map, right: Map): boolean { + if (left.size !== right.size) return false; + for (const [slug, state] of left) { + const other = right.get(slug); + if (!other || state.error !== other.error || state.good?.revision !== other.good?.revision) return false; + } + return true; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 8baa23e0..bbf27b30 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -161,6 +161,7 @@ type LaunchContribution = Extract; export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; + event?: string; requestId?: string; threadId?: string; } @@ -593,6 +594,7 @@ export class TerminalManager { session.resumeOnLaunch = resume; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); + session.metadata.turnCompleted = false; session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); @@ -649,6 +651,7 @@ export class TerminalManager { failureOrigin = "user"; } else { session.metadata.status = initialSessionStatus(session.metadata.provider); + session.metadata.turnCompleted = false; session.metadata.exitCode = null; session.metadata.failureDetails = null; if (launched.process) this.bindProcess(id, session, launched.process); @@ -805,8 +808,11 @@ export class TerminalManager { } const nextStatus = signal.state; - if (session.metadata.status === nextStatus) return; + const completed = nextStatus === "idle" && ["Stop", "StopFailure", "StopCancelled"].includes(signal.event ?? ""); + const nextTurnCompleted = nextStatus === "working" ? false : completed || Boolean(session.metadata.turnCompleted); + if (session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return; session.metadata.status = nextStatus; + session.metadata.turnCompleted = nextTurnCompleted; this.emitSession(session.metadata); } diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index ef124d86..9ec4dd2c 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -1,23 +1,26 @@ import { createHash, randomBytes } from "node:crypto"; -import { chmod, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { basename, isAbsolute, join } from "node:path"; import { lazyRequire } from "../../lazyRequire.ts"; import { NdjsonLineReader } from "../../../agent-runtime/ndjson.mjs"; import { MAX_UNIX_SOCKET_PATH_BYTES, closeServer, listenOnEndpoint, tokenDigest, tokenMatches } from "../gatewaySocket.ts"; -import type { CreateSessionRequest, SessionMetadata, SessionSnapshot, TerminalBufferSnapshot } from "../../../shared/contracts.ts"; +import type { AppSettings, CreateSessionRequest, PixelSkinApertures, SessionMetadata, SessionSnapshot, TerminalBufferSnapshot } from "../../../shared/contracts.ts"; import { IPC } from "../../../shared/contracts.ts"; import type { RuntimeLifecycleSignal } from "../agent-runtime/RuntimeGateway.ts"; import { WindowsPipeHostTransport, type AgentGatewaySocket } from "../agent-browser/WindowsPipeHostTransport.ts"; import { controlCapabilities, isControlProvider } from "./controlCapabilities.ts"; import { hasAutoMode, isLaunchProfile } from "../../../shared/autoMode.ts"; +import { MAX_PIXEL_SKIN_ARCHIVE_BYTES, type PixelSkinPackRegistry } from "../PixelSkinPackRegistry.ts"; +import type { SettingsStore } from "../SettingsStore.ts"; // Headless terminals are created on demand; the module loads with the first one. const xterm = lazyRequire("@xterm/headless"); const MAX_REQUEST_BYTES = 128 * 1024; const MAX_RESPONSE_BYTES = 256 * 1024; +const BUILT_IN_PIXEL_SKINS = ["sakura", "matrix", "forest-cabin", "gold-black", "cat", "gothic-eclipse"]; const MAX_RECEIPTS = 4096; // Refusals that happen before anything is written: a retry with the same // request id must be performed again instead of replaying the refusal. @@ -47,7 +50,8 @@ interface ControlRequest { instanceId: string; token: string; controller: string; - method: "create" | "list" | "status" | "screen" | "send" | "result" | "interrupt" | "choose" | "dismiss"; + method: "create" | "list" | "status" | "screen" | "send" | "result" | "interrupt" | "choose" | "dismiss" + | "skin-list" | "skin-install" | "skin-select"; params: Record; } @@ -74,6 +78,9 @@ interface OwnedSession { export interface AgentControlGatewayOptions { userDataPath: string; terminals: TerminalPort; + pixelSkinPacks?: PixelSkinPackRegistry; + settings?: SettingsStore; + onSettingsChanged?(settings: AppSettings): void; lifecycleEnabled(): boolean; platform?: NodeJS.Platform; windowsHostPath?: string; @@ -270,7 +277,7 @@ export class AgentControlGateway { this.sockets.add(socket); const lines = new NdjsonLineReader({ maxLineBytes: MAX_REQUEST_BYTES }); let handled = false; - const timer = setTimeout(() => socket.destroy(), 10_000); + let timer = setTimeout(() => socket.destroy(), 10_000); timer.unref(); socket.setNoDelay(true); socket.on("error", () => socket.destroy()); @@ -292,6 +299,11 @@ export class AgentControlGateway { let request: ControlRequest; try { request = this.parse(JSON.parse(line.toString("utf8"))); } catch { reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: "Invalid or unauthenticated control request." } }); return; } + if (request.method === "skin-install") { + clearTimeout(timer); + timer = setTimeout(() => socket.destroy(), 120_000); + timer.unref(); + } void this.dispatch(request).then( (result) => reply({ v: 1, id: request.id, ok: true, result }), (error: unknown) => reply({ v: 1, id: request.id, ok: false, error: { @@ -307,7 +319,8 @@ export class AgentControlGateway { || value.v !== 1 || typeof value.id !== "string" || !ID.test(value.id) || value.instanceId !== this.instanceId || typeof value.token !== "string" || !SECRET.test(value.token) || typeof value.controller !== "string" || !SECRET.test(value.controller) - || !["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss"].includes(String(value.method)) + || !["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss", + "skin-list", "skin-install", "skin-select"].includes(String(value.method)) || !record(value.params)) throw new Error("Invalid envelope"); if (!tokenMatches(value.token, this.tokenHash)) throw new Error("Invalid credential"); return value as unknown as ControlRequest; @@ -315,7 +328,7 @@ export class AgentControlGateway { private async dispatch(request: ControlRequest): Promise { const owner = hash(request.controller); - const mutating = ["create", "send", "interrupt", "choose", "dismiss"].includes(request.method); + const mutating = ["create", "send", "interrupt", "choose", "dismiss", "skin-install", "skin-select"].includes(request.method); if (!mutating) return this.perform(owner, request); const key = `${owner}:${request.id}`; const digest = hash(JSON.stringify([request.method, Object.entries(request.params).sort(([a], [b]) => a.localeCompare(b))])); @@ -352,6 +365,9 @@ export class AgentControlGateway { private async perform(owner: string, request: ControlRequest): Promise { if (this.closed) throw new ControlError("CLOSED", "Agent control is shutting down."); const params = request.params; + if (request.method === "skin-list" || request.method === "skin-install" || request.method === "skin-select") { + return this.performSkinOperation(request.method, params); + } if (request.method === "create") { fields(params, ["provider", "cwd", "title", "profile"]); if (!isControlProvider(params.provider) || !isLaunchProfile(params.profile)) throw new ControlError("INVALID_PARAMS", "Specify an agent provider (codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi) and an explicit normal, yolo or auto launch profile."); @@ -482,6 +498,57 @@ export class AgentControlGateway { private redactTurn(turn: Turn): Turn { return turn.result ? { ...turn, result: { ...turn.result, text: this.redact(turn.result.text) } } : turn; } + + private async performSkinOperation(method: "skin-list" | "skin-install" | "skin-select", params: Record): Promise { + const packs = this.options.pixelSkinPacks; + const settings = this.options.settings; + if (!packs || !settings) throw new ControlError("NOT_SUPPORTED", "Pixel theme control is unavailable."); + if (method === "skin-list") { + fields(params, []); + const current = settings.get(); + return { builtIn: BUILT_IN_PIXEL_SKINS, + installed: packs.list(), activeId: current.terminalBorderSkin, detail: current.terminalSkinDetail }; + } + if (method === "skin-install") { + fields(params, ["archivePath", "name", "activate", "detail", "apertures"]); + const archivePath = string(params.archivePath, 4096, "archivePath"); + if (!isAbsolute(archivePath)) throw new ControlError("INVALID_PARAMS", "archivePath must be absolute."); + const stat = await lstat(archivePath).catch(() => { throw new ControlError("INVALID_PARAMS", "Theme ZIP does not exist."); }); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PIXEL_SKIN_ARCHIVE_BYTES) { + throw new ControlError("INVALID_PARAMS", "Theme ZIP must be a regular file smaller than 150 MB."); + } + if (params.activate !== undefined && typeof params.activate !== "boolean") throw new ControlError("INVALID_PARAMS", "activate must be boolean."); + const detail = this.skinDetail(params.detail); + if (params.detail !== undefined && !params.activate) throw new ControlError("INVALID_PARAMS", "detail requires activate: true."); + const name = params.name === undefined ? basename(archivePath).replace(/\.zip$/i, "") : string(params.name, 64, "name"); + let pack; + try { pack = await packs.installZip(await readFile(archivePath), name, + params.apertures as PixelSkinApertures | undefined); } + catch (error) { throw new ControlError("INVALID_PACK", error instanceof Error ? error.message : "Invalid theme ZIP."); } + if (params.activate) { + const updated = await settings.update({ terminalBorderSkin: pack.id, ...(detail ? { terminalSkinDetail: detail } : {}) }); + this.options.onSettingsChanged?.(updated); + } + return { pack, active: Boolean(params.activate) }; + } + fields(params, ["skinId", "detail"]); + const skinId = string(params.skinId, 128, "skinId"); + if (!BUILT_IN_PIXEL_SKINS.includes(skinId) + && !packs.list().some((pack) => pack.id === skinId)) { + throw new ControlError("INVALID_PARAMS", "Unknown pixel theme ID."); + } + const detail = this.skinDetail(params.detail); + const updated = await settings.update({ terminalBorderSkin: skinId as AppSettings["terminalBorderSkin"], + ...(detail ? { terminalSkinDetail: detail } : {}) }); + this.options.onSettingsChanged?.(updated); + return { activeId: updated.terminalBorderSkin, detail: updated.terminalSkinDetail }; + } + + private skinDetail(value: unknown): "minimal" | "detailed" | undefined { + if (value === undefined) return undefined; + if (value !== "minimal" && value !== "detailed") throw new ControlError("INVALID_PARAMS", "detail must be minimal or detailed."); + return value; + } } export function codexComposerReady(screen: string): boolean { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9b4aa659..459101d2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ import type { CanvasNavigationOverrideStateEvent, CanvasNavigationPointerBindingInput, CanvasTTYApi, + CustomTerminalBorderSkinId, CreateSessionRequest, PluginBrowserOpenRequest, PluginBrowserOpenResponse, @@ -22,6 +23,10 @@ import type { PluginStorageChangeEvent, PluginUpdateStatus, ProviderId, + PixelSkinPackInstallRequest, + PixelSkinZipInstallRequest, + PixelSkinSlot, + PixelTerminalBorderSkinId, SessionBounds, SessionEvent, SessionRemovedEvent, @@ -69,7 +74,20 @@ const api: CanvasTTYApi = { }, settings: { get: () => ipcRenderer.invoke(IPC.settingsGet), - update: (patch: Partial) => ipcRenderer.invoke(IPC.settingsUpdate, patch) + update: (patch: Partial) => ipcRenderer.invoke(IPC.settingsUpdate, patch), + onChanged: (listener: (settings: AppSettings) => void) => subscribe(IPC.settingsChanged, listener) + }, + skins: { + list: () => ipcRenderer.invoke(IPC.terminalBorderSkinsList), + get: (id: CustomTerminalBorderSkinId) => ipcRenderer.invoke(IPC.terminalBorderSkinsGet, id), + onChanged: (listener: () => void) => subscribe(IPC.terminalBorderSkinsChanged, listener) + }, + pixelSkins: { + list: () => ipcRenderer.invoke(IPC.pixelSkinsList), + install: (request: PixelSkinPackInstallRequest) => ipcRenderer.invoke(IPC.pixelSkinsInstall, request), + installZip: (request: PixelSkinZipInstallRequest) => ipcRenderer.invoke(IPC.pixelSkinsInstallZip, request), + readAsset: (id: PixelTerminalBorderSkinId, slot: PixelSkinSlot) => ipcRenderer.invoke(IPC.pixelSkinsReadAsset, id, slot), + onChanged: (listener: () => void) => subscribe(IPC.pixelSkinsChanged, listener) }, agents: { availability: () => ipcRenderer.invoke(IPC.agentsAvailability), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4444ce49..592ea10c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -40,6 +40,10 @@ import { DEFAULT_SHORTCUTS } from "../../shared/contracts"; import { normalizeExternalUrl } from "../../shared/externalUrl"; +import { + createTerminalBorderSkinStyleController, + type TerminalBorderSkinStyleController +} from "./lib/skinStyles"; import { TitleBar } from "./components/TitleBar"; import { Toast } from "./components/Toast"; import { AgentLaunchDialog } from "./features/launcher/AgentLaunchDialog"; @@ -50,6 +54,9 @@ import { persistSettingsUpdate } from "./features/settings/persistSettings"; import { PluginBrowserOpenQueue } from "./features/plugins/PluginBrowserOpenQueue"; import { TerminalLinkDialog } from "./features/terminal/TerminalLinkDialog"; import { WorkspaceCanvas } from "./features/workspace/WorkspaceCanvas"; +import { isPixelSkinThemeId } from "./features/skins/skinCatalog"; +import { isPixelSkinPackId } from "./features/skins/SkinAssets"; +import { expandedPixelSkinCardBounds, PIXEL_SKIN_CARD_SIZE } from "./features/skins/pixelSkinCardGeometry"; import type { LimitsLoadState } from "./features/home/homeModel"; import { t } from "./lib/i18n"; import { AGENT_PROVIDERS, LIMIT_PROVIDERS } from "./lib/providers"; @@ -93,6 +100,10 @@ const FALLBACK_SETTINGS: AppSettings = { uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", pattern: "dots", + terminalBorderSkin: "classic", + terminalSkinDetail: "detailed", + terminalSkinAnimationEnabled: true, + appSkin: "classic", snapToGrid: true, invertTerminalWheel: true, invertCanvasWheel: false, @@ -143,6 +154,28 @@ const EMPTY_BROWSER_SNAPSHOT: BrowserSnapshot = { const DEFAULT_FOCUS_ZOOM = 0.92; const PLUGIN_CANVAS_FOCUS_ZOOM = 1; +function TerminalBorderSkinStyleHost({ skinId }: { skinId: AppSettings["terminalBorderSkin"] }): null { + const controllerRef = useRef(null); + const activeSkinIdRef = useRef(skinId); + activeSkinIdRef.current = skinId; + + useEffect(() => { + const controller = createTerminalBorderSkinStyleController(window.canvasTTY.skins, document); + controllerRef.current = controller; + controller.setActive(activeSkinIdRef.current); + return () => { + controller.dispose(); + if (controllerRef.current === controller) controllerRef.current = null; + }; + }, []); + + useEffect(() => { + controllerRef.current?.setActive(skinId); + }, [skinId]); + + return null; +} + function customHomeAccentStyle(colors: HomeAccentColors): React.CSSProperties { const launcherTile = mixHexWithWhite(colors.launcher, 0.62); return { @@ -261,6 +294,9 @@ export function App(): React.JSX.Element { }); const settingsRequest = window.canvasTTY.settings.get(); + const unsubscribeSettings = window.canvasTTY.settings.onChanged((next) => { + if (active) setSettings(next); + }); const availabilityRequest = window.canvasTTY.agents.availability(); const sessionsRequest = window.canvasTTY.terminal.list().then((loadedSessions) => { if (active) setSessions((current) => mergeSessionSnapshots(current, loadedSessions)); @@ -291,6 +327,7 @@ export function App(): React.JSX.Element { active = false; unsubscribeSession(); unsubscribeRemoved(); + unsubscribeSettings(); }; }, [showToast]); @@ -391,8 +428,11 @@ export function App(): React.JSX.Element { environment?: SessionEnvironmentChoice ): Promise => { const currentSettings = settingsRef.current; + const pixelSkin = isPixelSkinThemeId(currentSettings.terminalBorderSkin) + || isPixelSkinPackId(currentSettings.terminalBorderSkin); + const cardSize = pixelSkin ? PIXEL_SKIN_CARD_SIZE : DEFAULT_SESSION_SIZE; const position = requestedCenter - ? centeredWindowPosition(requestedCenter, DEFAULT_SESSION_SIZE) + ? centeredWindowPosition(requestedCenter, cardSize) : findNearHomeSessionPosition( { position: { x: 0, y: 0 }, size: homeGridPixelSize(currentSettings.homeGridSize) }, [ @@ -402,26 +442,28 @@ export function App(): React.JSX.Element { ...(currentSettings.browserCanvas ? [currentSettings.browserCanvas] : []), ...pendingSessionPlacements.current ], - DEFAULT_SESSION_SIZE + cardSize ); // Reserve the slot until the async create finishes, so fast parallel launches // cannot both choose the same free position before React renders either card. const reservation: SessionBounds | null = requestedCenter ? null - : { position, size: DEFAULT_SESSION_SIZE }; + : { position, size: cardSize }; if (reservation) pendingSessionPlacements.current.push(reservation); try { const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role, ...(launchOptions ? { launchOptions } : {}), ...(environment ? { environment } : {}) }); - sessionsRef.current = upsertSnapshot(sessionsRef.current, session); - setSessions((current) => upsertSnapshot(current, session)); + const sizedSession = pixelSkin ? { ...session, size: { ...cardSize } } : session; + if (pixelSkin) window.canvasTTY.terminal.setBounds(session.id, { position, size: sizedSession.size }); + sessionsRef.current = upsertSnapshot(sessionsRef.current, sizedSession); + setSessions((current) => upsertSnapshot(current, sizedSession)); setActiveSessionId(session.id); await saveSettings({ lastDirectory: cwd }); isHomeCamera.current = false; - setCamera(focusCamera(position, session.size)); - return session; + setCamera(focusCamera(position, sizedSession.size)); + return sizedSession; } finally { if (reservation) { pendingSessionPlacements.current = pendingSessionPlacements.current.filter((item) => item !== reservation); @@ -513,10 +555,13 @@ export function App(): React.JSX.Element { }, [settings.locale, showToast]); const changeSessionBounds = useCallback((id: string, bounds: SessionBounds): void => { + const pixelSkin = isPixelSkinThemeId(settingsRef.current.terminalBorderSkin) + || isPixelSkinPackId(settingsRef.current.terminalBorderSkin); + const fixedBounds = pixelSkin ? { position: bounds.position, size: { ...PIXEL_SKIN_CARD_SIZE } } : bounds; setSessions((current) => current.map((session) => session.id === id - ? { ...session, position: bounds.position, size: bounds.size } + ? { ...session, position: fixedBounds.position, size: fixedBounds.size } : session)); - window.canvasTTY.terminal.setBounds(id, bounds); + window.canvasTTY.terminal.setBounds(id, fixedBounds); }, []); const toggleSessionFullscreen = useCallback((id: string): void => { @@ -534,6 +579,15 @@ export function App(): React.JSX.Element { } }, [fullscreenSessionId]); + useEffect(() => { + if (!ready || !(isPixelSkinThemeId(settings.terminalBorderSkin) + || isPixelSkinPackId(settings.terminalBorderSkin))) return; + const expanded = expandedPixelSkinCardBounds(sessions); + for (const { id, bounds } of expanded) changeSessionBounds(id, bounds); + const active = expanded.find(({ id }) => id === activeSessionId); + if (active && !isHomeCamera.current) setCamera(focusCamera(active.bounds.position, active.bounds.size)); + }, [activeSessionId, changeSessionBounds, ready, sessions, settings.terminalBorderSkin]); + const changePluginCanvasBounds = useCallback((id: string, bounds: SessionBounds): void => { const pluginCanvas = settingsRef.current.pluginCanvas.map((instance) => instance.id === id ? { ...instance, position: bounds.position, size: bounds.size } @@ -1143,7 +1197,8 @@ export function App(): React.JSX.Element { }, [agentAvailability, homeEditDraft, settings]); return ( -
+
+
{!ready &&
{t(settings.locale, "loading")}
} diff --git a/src/renderer/src/assets/theme-backgrounds/cat.png b/src/renderer/src/assets/theme-backgrounds/cat.png new file mode 100644 index 00000000..1e12215b Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/cat.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/forest-cabin.png b/src/renderer/src/assets/theme-backgrounds/forest-cabin.png new file mode 100644 index 00000000..ed0c7f01 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/forest-cabin.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/gold-black.png b/src/renderer/src/assets/theme-backgrounds/gold-black.png new file mode 100644 index 00000000..b79a15fd Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/gold-black.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/gothic-eclipse.png b/src/renderer/src/assets/theme-backgrounds/gothic-eclipse.png new file mode 100644 index 00000000..a9efaff5 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/gothic-eclipse.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/matrix.png b/src/renderer/src/assets/theme-backgrounds/matrix.png new file mode 100644 index 00000000..308773d9 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/matrix.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/sakura.png b/src/renderer/src/assets/theme-backgrounds/sakura.png new file mode 100644 index 00000000..1810c0c0 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/sakura.png differ diff --git a/src/renderer/src/features/settings/PixelSkinPackCreator.tsx b/src/renderer/src/features/settings/PixelSkinPackCreator.tsx new file mode 100644 index 00000000..24a68fe1 --- /dev/null +++ b/src/renderer/src/features/settings/PixelSkinPackCreator.tsx @@ -0,0 +1,262 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { DEFAULT_PIXEL_SKIN_APERTURES } from "../../../../shared/contracts"; +import type { + LocaleId, + PixelSkinAperture, + PixelSkinApertures, + PixelSkinPackSummary, + PixelSkinSlot +} from "../../../../shared/contracts"; +import { UiIcon } from "../../components/UiIcon"; +import { pixelPackSlotForFilename } from "./pixelPackFiles"; +import { PIXEL_THEME_AGENT_GUIDE_URL, pixelThemeAgentPrompt } from "./pixelThemeAgentPrompt"; + +const LEVELS = ["minimal", "detailed", "master"] as const; +const STATES = ["idle", "working", "completed"] as const; +const SLOTS: readonly PixelSkinSlot[] = LEVELS.flatMap((level) => STATES.map((state) => `${level}_${state}` as PixelSkinSlot)).concat("background"); + +function isPng(file: File): boolean { + return file.name.toLowerCase().endsWith(".png") && (!file.type || file.type === "image/png"); +} + +export function PixelSkinPackCreator({ locale, onCreated }: { + locale: LocaleId; + onCreated(pack: PixelSkinPackSummary): void; +}): React.JSX.Element { + const ru = locale === "ru"; + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [zipFile, setZipFile] = useState(null); + const [files, setFiles] = useState>>({}); + const [previews, setPreviews] = useState>>({}); + const [apertures, setApertures] = useState(DEFAULT_PIXEL_SKIN_APERTURES); + const [apertureLevel, setApertureLevel] = useState("minimal"); + const [error, setError] = useState(null); + const [installing, setInstalling] = useState(false); + const [promptCopied, setPromptCopied] = useState(false); + const bulkInput = useRef(null); + const zipInput = useRef(null); + + useEffect(() => { + const urls = Object.fromEntries(Object.entries(files).map(([slot, file]) => [slot, URL.createObjectURL(file)])); + setPreviews(urls); + return () => Object.values(urls).forEach((url) => URL.revokeObjectURL(url)); + }, [files]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape" && !installing) setOpen(false); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, installing]); + + const assign = (slot: PixelSkinSlot, file: File | undefined): void => { + if (!file) return; + if (!isPng(file)) { + setError(ru ? "Нужен файл PNG." : "A PNG file is required."); + return; + } + setFiles((current) => ({ ...current, [slot]: file })); + setZipFile(null); + setError(null); + }; + + const assignMany = (selected: FileList | File[]): void => { + const assigned: Partial> = {}; + for (const file of Array.from(selected)) { + if (!isPng(file)) continue; + const slot = pixelPackSlotForFilename(file.name); + if (slot) assigned[slot] = file; + } + if (Object.keys(assigned).length === 0) { + setError(ru ? "Не удалось определить роли по именам PNG." : "No PNG roles were found in filenames."); + return; + } + setFiles((current) => ({ ...current, ...assigned })); + setZipFile(null); + setError(null); + }; + + const assignZip = (file: File | undefined): void => { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".zip") || file.size > 150 * 1024 * 1024) { + setError(ru ? "Нужен ZIP размером до 150 МБ." : "Choose a ZIP smaller than 150 MB."); + return; + } + setZipFile(file); + setFiles({}); + if (!name.trim()) setName(file.name.replace(/\.zip$/i, "").slice(0, 64)); + setError(null); + }; + + const copyAgentPrompt = (): void => { + window.canvasTTY.clipboard.writeText(pixelThemeAgentPrompt(locale)); + setPromptCopied(true); + }; + + const openAgentGuide = (): void => { + void window.canvasTTY.external.openUrl(PIXEL_THEME_AGENT_GUIDE_URL).catch((cause: unknown) => { + setError(cause instanceof Error ? cause.message : String(cause)); + }); + }; + + const install = async (): Promise => { + if (!name.trim() || (!zipFile && SLOTS.some((slot) => !files[slot]))) return; + setInstalling(true); + setError(null); + try { + const pack = zipFile + ? await window.canvasTTY.pixelSkins.installZip({ + name: name.trim(), apertures, archive: new Uint8Array(await zipFile.arrayBuffer()) + }) + : await (async () => { + const bytes = await Promise.all(SLOTS.map(async (slot) => [slot, new Uint8Array(await files[slot]!.arrayBuffer())] as const)); + return window.canvasTTY.pixelSkins.install({ + name: name.trim(), apertures, + files: Object.fromEntries(bytes) as Record + }); + })(); + onCreated(pack); + setOpen(false); + setName(""); + setFiles({}); + setZipFile(null); + setApertures(DEFAULT_PIXEL_SKIN_APERTURES); + setApertureLevel("minimal"); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setInstalling(false); + } + }; + + const count = SLOTS.filter((slot) => files[slot]).length; + const aperture: PixelSkinAperture = apertures[apertureLevel]; + return ( + <> + + {open && createPortal( +
{ + if (event.target === event.currentTarget && !installing) setOpen(false); + }}> +
+
+

{ru ? "Новая пиксельная тема" : "New pixel theme"}

+ +
+
+
+ + { + if (event.target.files) assignMany(event.target.files); + event.target.value = ""; + }} /> + { + assignZip(event.target.files?.[0]); + event.target.value = ""; + }} /> + + +
+
+ + +
+ {zipFile ?
+ {zipFile.name} + +
:
+ {LEVELS.map((level) => STATES.map((state) => { + const slot = `${level}_${state}` as PixelSkinSlot; + const label = `${level} · ${state}`; + return ( + + ); + }))} +
} +
+ {!zipFile && } +
+
+ {LEVELS.map((level) => ( + + ))} +
+ {(["left", "right", "top", "bottom"] as const).map((edge) => ( + + ))} +
+
+
+
+ {error ?? (zipFile ? zipFile.name : `${count}/10 PNG`)} + +
+
+
, document.body + )} + + ); +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index 32440890..b6fc04c4 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import appManifest from "../../../../../package.json"; import type { AppSettings, + AppSkinId, AgentCliAvailability, AgentProviderId, BrowserActivityEvent, @@ -14,6 +15,7 @@ import type { CanvasOverlayPlacement, CanvasPatternId, CanvasWheelCaptureMode, + CustomTerminalBorderSkinId, EdgePanSpeed, FocusActivation, GithubPluginSearchResult, @@ -29,10 +31,16 @@ import type { PluginManifest, PluginInstallPreview, PluginUpdateStatus, + PixelSkinPackSummary, + PixelSkinPreferredDetail, + PixelTerminalBorderSkinId, RadialLauncherItemId, SessionRestoreMode, SessionRowColorMode, + SessionStatus, ShortcutAction, + TerminalBorderSkinListItem, + TerminalBorderSkinId, UpdaterState, ZoomSensitivity } from "../../../../shared/contracts"; @@ -69,6 +77,11 @@ import { } from "../../lib/providers"; import { shortcutFromKeyboardEvent, shortcutFromPointerEvent } from "../../lib/shortcuts"; import { t } from "../../lib/i18n"; +import { + createTerminalBorderSkinPreviewStyleController, + isCustomTerminalBorderSkinId, + normalizeTerminalBorderSkinList +} from "../../lib/skinStyles"; import { PluginSettingsSection } from "../plugins/PluginSettingsSection"; import { HomeAppearanceSettings } from "../home/HomeAppearanceSettings"; import { @@ -86,6 +99,12 @@ import { UpdatesSettings } from "./UpdatesSettings"; import { setCanvasLauncherItemEnabled } from "../launcher/canvasLauncher"; import { itemLabel } from "../launcher/QuickRadialMenu"; import { setRadialLauncherItemEnabled } from "../launcher/radialLauncher"; +import { Canvas2DSkinView } from "../skins/Canvas2DSkinView"; +import { isPixelSkinPackId, PILOT_SKIN_ASSETS } from "../skins/SkinAssets"; +import { isPixelSkinThemeId, pixelSkinAssetFilename } from "../skins/skinCatalog"; +import type { PixelSkinThemeId } from "../skins/skinCatalog"; +import type { SkinDetailLevel } from "../skins/SkinLayout"; +import { PixelSkinPackCreator } from "./PixelSkinPackCreator"; type SettingsSection = "general" | "appearance" | "agents" | "controls" | "browser" | "plugins" | "updates" | "about"; @@ -540,6 +559,30 @@ export function SettingsPanel({ onChange={(value) => void onChange({ pattern: value as CanvasPatternId })} /> + + void onChange({ terminalBorderSkin })} + /> + + + void onChange({ terminalSkinDetail: terminalSkinDetail as PixelSkinPreferredDetail })} + /> + + + void onChange({ appSkin })} + /> +
-
- {!summaryMode && ( - - )} - {session.exitCode !== null && ( - - )} - {session.exitCode !== null && session.provider !== "terminal" && ( - - )} - {hasOptions && ( - - )} - - -
+ {!pixelControls && terminalActions}
+ {pixelSkinTheme && ( + + )} + {pixelSkinTheme && (["top", "right", "bottom", "left"] as const).map((edge) => ( +