diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 7acaf490d..609d8cb14 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -65,7 +65,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself. - With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap. - One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty. -- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. +- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`, as do the terminal card options menu and the canvas shortcut-hints panel. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. - Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`. - General Settings places the macOS/Windows/Linux/Custom keyboard preset after language, followed by an expandable Keyboard shortcuts section for canvas, terminal, Codex editor and canvas navigation bindings. Recording a shortcut selects Custom; choosing a named preset replaces bindings. Optional commands can be disabled, while submit, newline and whole-draft selection remain bound. Canvas and terminal bindings update immediately; native Codex editor bindings apply to new or restarted cards. Focused terminal and text inputs retain ownership of canvas keyboard shortcuts on every platform. - Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. diff --git a/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index 72f2e886f..c4e621d2c 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -16,6 +16,8 @@ "apiVersion": { "enum": [1, 2] }, "id": { "type": "string", "minLength": 3, "maxLength": 80, "pattern": "^(?!.*\\.\\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, + "name.ru": { "description": "The name when the app is in Russian (like description.ru); name otherwise.", "type": "string", "minLength": 1, "maxLength": 80 }, + "name.en": { "description": "The name when the app is in English (like description.en); name otherwise.", "type": "string", "minLength": 1, "maxLength": 80 }, "version": { "type": "string", "maxLength": 40, "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, "description": { "type": "string", "minLength": 1, "maxLength": 2000 }, "description.ru": { "type": "string", "minLength": 1, "maxLength": 2000 }, @@ -261,6 +263,8 @@ "properties": { "key": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, + "label.ru": { "description": "The label when the app is in Russian (like description.ru); label otherwise.", "type": "string", "minLength": 1, "maxLength": 80 }, + "label.en": { "description": "The label when the app is in English (like description.en); label otherwise.", "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["boolean", "select", "text"] }, "options": { "type": "array", "minItems": 1, "maxItems": 16, @@ -285,6 +289,8 @@ "id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" }, "kind": { "enum": ["home-widget", "canvas-app", "window"] }, "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "title.ru": { "description": "The title when the app is in Russian (like description.ru); title otherwise.", "type": "string", "minLength": 1, "maxLength": 80 }, + "title.en": { "description": "The title when the app is in English (like description.en); title otherwise.", "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "entry": { "type": "string", "minLength": 1, "maxLength": 180, "pattern": "^(?!/)(?!.*\\\\)(?!\\.\\.?/)(?!.*(?:/\\.\\.?/|/\\.\\.?$|//)).+\\.html$" }, "icon": { "type": "string", "minLength": 1, "maxLength": 180, "pattern": "^(?!/)(?!.*\\\\)(?!\\.\\.?/)(?!.*(?:/\\.\\.?/|/\\.\\.?$|//)).+$" }, diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index 8ab1b19a3..e1fb96f3c 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -90,6 +90,9 @@ export interface CanvasTTYPluginContext { appearance: { locale: "ru" | "en"; palette: "sage" | "lilac" | "night"; + /** The app's UI scale (Settings → Appearance; 1 is 100%, from 0.85 to 1.25), the value CanvasTTY scales its own + * text and controls by. Sent again whenever the person changes it. */ + uiScale: number; }; } @@ -392,7 +395,11 @@ export interface CanvasTTYServiceLaunch { delegable?: boolean; } -export type CanvasTTYLaunchField = +export type CanvasTTYLaunchField = { + /** The label when the app is in Russian / English (up to 80 characters, like `description.ru`); `label` otherwise. */ + "label.ru"?: string; + "label.en"?: string; +} & ( | { key: string; label: string; kind: "boolean"; default?: boolean } | { key: string; label: string; kind: "select"; options: Array<{ value: string; label: string }>; default?: string; @@ -400,7 +407,8 @@ export type CanvasTTYLaunchField = * text up to 200 characters, which `canvastty.launch.prepare` must check. */ optionsFrom?: "service"; } - | { key: string; label: string; kind: "text"; maxLength?: number; default?: string }; + | { key: string; label: string; kind: "text"; maxLength?: number; default?: string } +); /** Params of the host request `canvastty.launch.prepare`: launches that chose this plugin, and with `policy` every agent launch. */ export interface CanvasTTYLaunchContext { diff --git a/docs/plugins.md b/docs/plugins.md index 0047ad17b..e4d14d657 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -91,6 +91,8 @@ Plugin and contribution IDs are stable persistence keys. Do not rename them afte `platforms` is optional; when present it must include `"canvastty"` or direct install/update is rejected. `minHostVersion` is informational: the showcase marks plugins that target a newer host, but it does not block installation. This lets separately packaged release builds keep using compatible source packages without treating older minimum versions as mismatches. +Texts the person reads may come in the app's languages, like `description.ru`/`description.en`: the plugin `name` may add `"name.ru"` and `"name.en"`, a contribution's `title` (a canvas app, the settings page, a HOME widget, a window) may add `"title.ru"` and `"title.en"`, and a launch field's `label` may add `"label.ru"` and `"label.en"`. Each has the length limit of the text it translates (80 characters). CanvasTTY shows the one for its current language, and the plain text otherwise. Other suffixes are rejected. + ### Optional modules A modular manifest declares integrity-checked coreFiles plus up to 16 optional modules. Every file entry contains path, exact bytes, and a SHA-256 digest. CanvasTTY downloads only the manifest for inspection, shows checkboxes, per-module size and permissions, then downloads only the core and selected module files. Changing the selection later replaces the installed package atomically and removes deselected files. A contribution may set module to disappear when that module is not installed. @@ -192,7 +194,7 @@ One service per plugin may add a `launch` block. Its fields appear in the agent }] ``` -Up to 8 fields; `kind` is `boolean`, `select` (1–16 options) or `text` (at most 200 characters, or `maxLength`). `appliesTo` lists agent providers; omitted means every agent. The chosen values are checked against the fields, saved in the card's session record (at most 4 KB per plugin), and reused on restart and restore. They are not secret: put keys in the plugin's `secrets`, never in a field. +Up to 8 fields; `kind` is `boolean`, `select` (1–16 options) or `text` (at most 200 characters, or `maxLength`). `appliesTo` lists agent providers; omitted means every agent. The chosen values are checked against the fields, saved in the card's session record (at most 4 KB per plugin), and reused on restart and restore. They are not secret: put keys in the plugin's `secrets`, never in a field. A field's `label` may add `"label.ru"` and `"label.en"` (see the manifest above). A `select` with `"optionsFrom": "service"` also lists choices the service offers, such as its own accounts. When the launcher opens, CanvasTTY asks the service `canvastty.launch.options` `{ provider, fields: [keys] }` and waits at most 3 s; the answer `{ "": [{ value, label }] }` adds up to 64 choices per field after the declared ones (which stay required and are all the launcher shows when the service does not answer). Because such a list can change after a card was saved, its value is accepted as any text up to 200 characters without control characters, and `canvastty.launch.prepare` must check it and refuse a value it no longer knows. @@ -527,7 +529,7 @@ const current = host.visibility(); // "visible" | "hidden" Plain `document.addEventListener("visibilitychange", …)` works too, without the SDK. The host message behind it is `{ source: "canvastty-host", type: "visibility", state: "visible" | "hidden" }`; plugins do not need to handle it themselves. -Context updates include the active CanvasTTY locale and palette. Plugins own their internal localization and styling; they should remain legible at the contribution's intended size and should not invent loading progress, sessions, status, limits, or telemetry. +Context updates include the active CanvasTTY locale and palette. Plugins own their internal localization and styling; they should remain legible at the contribution's intended size and should not invent loading progress, sessions, status, limits, or telemetry. `appearance.uiScale` is the app's UI scale (Settings → Appearance; 1 is 100%, 0.85 to 1.25), the value CanvasTTY scales its own text and controls by; a page or canvas app that should match the app scales its text by it, for example `` document.documentElement.style.fontSize = `${13 * appearance.uiScale}px` ``. A new context arrives whenever the person changes the locale, the palette or the scale; a separate window reads it when it loads. ## Install and manage diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 5d2cfe9d1..5e20c449d 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -91,6 +91,8 @@ ID плагина и contribution — стабильные ключи persistenc Поле `platforms` необязательно; если оно задано, список должен содержать `"canvastty"`, иначе прямая установка или обновление отклоняются. `minHostVersion` носит информационный характер: витрина помечает плагины для более новой версии host, но не блокирует установку. Старые минимальные версии не считаются несовместимостью. +Тексты, которые видит человек, можно дать на языках приложения, как `description.ru`/`description.en`: к `name` плагина можно добавить `"name.ru"` и `"name.en"`, к `title` contribution (canvas-приложение, страница настроек, виджет HOME, окно) — `"title.ru"` и `"title.en"`, к `label` поля запуска — `"label.ru"` и `"label.en"`. Ограничение длины такое же, как у исходного текста (80 символов). CanvasTTY показывает вариант для текущего языка, а без него — обычный текст. Другие суффиксы отклоняются. + ### Необязательные модули Модульный manifest объявляет проверяемые по целостности coreFiles и до 16 необязательных modules. Для каждого файла задаются path, точный размер bytes и SHA-256. CanvasTTY загружает для предпросмотра только manifest, показывает галочки, размер и разрешения каждого модуля, а затем скачивает только ядро и выбранные модули. Последующее изменение выбора атомарно заменяет установленный пакет и удаляет файлы отключённых модулей. Поле module у contribution скрывает его, если соответствующий модуль не установлен. @@ -173,7 +175,7 @@ host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); }] ``` -До 8 полей; `kind`: `boolean`, `select` (1–16 вариантов) или `text` (до 200 символов или `maxLength`). `appliesTo` перечисляет провайдеров-агентов; без него плагин доступен всем агентам. Выбранные значения проверяются по полям, сохраняются в записи сессии окна (до 4 КБ на плагин) и используются при перезапуске и восстановлении. Они не секретны: ключи хранят в `secrets` плагина, а не в поле. +До 8 полей; `kind`: `boolean`, `select` (1–16 вариантов) или `text` (до 200 символов или `maxLength`). `appliesTo` перечисляет провайдеров-агентов; без него плагин доступен всем агентам. Выбранные значения проверяются по полям, сохраняются в записи сессии окна (до 4 КБ на плагин) и используются при перезапуске и восстановлении. Они не секретны: ключи хранят в `secrets` плагина, а не в поле. К `label` поля можно добавить `"label.ru"` и `"label.en"` (см. манифест выше). `select` с `"optionsFrom": "service"` дополнительно показывает варианты, которые предлагает сервис, например его собственные учётные записи. При открытии окна запуска CanvasTTY спрашивает сервис `canvastty.launch.options` `{ provider, fields: [ключи] }` и ждёт не более 3 с; ответ `{ "<ключ>": [{ value, label }] }` добавляет до 64 вариантов на поле после объявленных (они по-прежнему обязательны, и только они видны, если сервис не ответил). Такой список может измениться после сохранения окна, поэтому значение принимается как любой текст до 200 символов без управляющих символов, а `canvastty.launch.prepare` обязан его проверить и отказать, если такого варианта больше нет. @@ -487,7 +489,7 @@ if (library) { Рекомендуемый запуск: вызвать `listLibraries()`, предложить `pickLibrary()` только если разрешённых папок ещё нет, просканировать выбранную библиотеку, восстановить очередь и настройки из `storage`, затем получить и разобрать плейлисты. Отозванную или перемещённую папку показывайте явным состоянием «недоступно» и предложите выбрать её заново. -Context сообщает текущие locale и palette CanvasTTY. Локализация и внутренние стили — ответственность плагина. Плагин не должен выдумывать progress, sessions, status, limits или telemetry. +Context сообщает текущие locale и palette CanvasTTY. Локализация и внутренние стили — ответственность плагина. Плагин не должен выдумывать progress, sessions, status, limits или telemetry. `appearance.uiScale` — масштаб интерфейса приложения (Настройки → Оформление; 1 — это 100%, от 0.85 до 1.25), тот же, на который CanvasTTY масштабирует свой текст и элементы; страница или canvas-приложение, которое должно совпадать с приложением, масштабирует свой текст на него, например `` document.documentElement.style.fontSize = `${13 * appearance.uiScale}px` ``. Новый context приходит при каждой смене языка, палитры или масштаба; отдельное окно читает его при загрузке. ### Видимость diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index c91ff2c60..e75ecef26 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -91,6 +91,8 @@ hooks/audit.mjs `platforms` 为可选字段;一旦声明,就必须包含 `"canvastty"`,否则直接安装或更新会被拒绝。`minHostVersion` 仅用于提示:展示页会标记需要更高宿主版本的插件,但不会阻止安装;较旧的最低版本不会被视为不兼容。 +面向用户的文字可以像 `description.ru`/`description.en` 一样提供应用语言版本:插件的 `name` 可加 `"name.ru"` 和 `"name.en"`,contribution 的 `title`(画布应用、设置页、HOME 小组件、窗口)可加 `"title.ru"` 和 `"title.en"`,启动字段的 `label` 可加 `"label.ru"` 和 `"label.en"`。长度限制与原文相同(80 个字符)。CanvasTTY 显示当前语言的版本,没有时显示原文。其他后缀会被拒绝。 + ### 可选模块 模块化 manifest 可声明经过完整性校验的 coreFiles 和最多 16 个可选 modules。每个文件都包含 path、精确的 bytes 大小和 SHA-256。CanvasTTY 在预览时只下载 manifest,显示模块复选框、大小和权限,然后仅下载核心文件与用户选择的模块。之后更改选择时会原子替换插件包,并删除已取消模块的文件。Contribution 可以通过 module 字段在模块未安装时隐藏。 @@ -171,7 +173,7 @@ host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); }] ``` -最多 8 个字段;`kind` 为 `boolean`、`select`(1–16 个选项)或 `text`(最多 200 个字符或 `maxLength`)。`appliesTo` 列出适用的智能体服务商,省略表示所有智能体。所选值按字段校验,保存在卡片的会话记录中(每个插件最多 4 KB),并在重启和恢复时复用。它们不是机密:密钥应放在插件的 `secrets` 中,而不是字段里。 +最多 8 个字段;`kind` 为 `boolean`、`select`(1–16 个选项)或 `text`(最多 200 个字符或 `maxLength`)。`appliesTo` 列出适用的智能体服务商,省略表示所有智能体。所选值按字段校验,保存在卡片的会话记录中(每个插件最多 4 KB),并在重启和恢复时复用。它们不是机密:密钥应放在插件的 `secrets` 中,而不是字段里。 字段的 `label` 可加 `"label.ru"` 和 `"label.en"`(见上文清单)。 带 `"optionsFrom": "service"` 的 `select` 还会列出服务提供的选项,例如插件自己的账户。启动器打开时,CanvasTTY 向服务发送 `canvastty.launch.options` `{ provider, fields: [键] }`,最多等待 3 秒;回答 `{ "<键>": [{ value, label }] }` 在声明的选项之后为每个字段追加最多 64 个选项(声明的选项仍然必需,服务未回答时启动器只显示它们)。由于此类列表可能在卡片保存后变化,其值接受为不含控制字符、最多 200 个字符的任意文本,`canvastty.launch.prepare` 必须检查该值,并拒绝已不存在的选项。 @@ -485,7 +487,7 @@ if (library) { 推荐的启动流程:调用 `listLibraries()`;仅在没有已授权文件夹时才通过 `pickLibrary()` 请求选择文件夹;扫描所选音乐库;从 `storage` 恢复队列和偏好;然后列出并解析播放列表。把已撤销或已移动的文件夹当作明确的不可用状态处理,并让用户重新选择。 -上下文更新包含当前 CanvasTTY 的语言环境和配色方案。插件自行负责其内部本地化和样式;应在 contribution 的预期尺寸下保持可读,且不得虚构加载进度、会话、状态、限额或遥测。 +上下文更新包含当前 CanvasTTY 的语言环境和配色方案。插件自行负责其内部本地化和样式;应在 contribution 的预期尺寸下保持可读,且不得虚构加载进度、会话、状态、限额或遥测。 `appearance.uiScale` 是应用的界面缩放(设置 → 外观;1 表示 100%,范围 0.85 到 1.25),也是 CanvasTTY 缩放自身文字和控件所用的值;希望与应用一致的页面或画布应用按它缩放文字,例如 `` document.documentElement.style.fontSize = `${13 * appearance.uiScale}px` ``。每当用户更改语言、配色或缩放时都会发送新的上下文;独立窗口在加载时读取它。 ### 可见性 diff --git a/src/main/index.ts b/src/main/index.ts index b9832dcee..574cd9063 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,6 +58,7 @@ import { MaterialService } from "./services/materials/MaterialService"; import { HandoffService } from "./services/materials/HandoffService"; import { electronImageOps } from "./services/materials/electronImageOps"; import { MATERIAL_SCHEME } from "../shared/materials.ts"; +import { contributionTitle } from "../shared/pluginLocalizedText.ts"; import { PluginSecretsService } from "./services/PluginSecretsService"; import { ProviderSecretsService } from "./services/ProviderSecretsService"; import { listProviderDirectory, type ProviderDirectorySources } from "./services/providerDirectory"; @@ -1315,7 +1316,7 @@ async function openPluginWindow(pluginId: string, contributionId: string): Promi height: contribution.defaultSize.height, minWidth: contribution.minSize?.width ?? 320, minHeight: contribution.minSize?.height ?? 220, - title: contribution.title, + title: contributionTitle(contribution, editMenuLocale()), backgroundColor: "#353442", webPreferences: { preload: join(__dirname, "../preload/plugin.cjs"), diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 00d3feb6c..d7b31bb71 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -706,7 +706,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { modules: plugin.selectedModules }, contribution: { id: contribution.id, kind: contribution.kind, title: contribution.title }, - appearance: { locale: settings.get().locale, palette: settings.get().palette } + appearance: { locale: settings.get().locale, palette: settings.get().palette, uiScale: settings.get().uiScale } }; } if (method === "storage.get") return plugins.storageGet(pluginId, stringValue(values.key, "key")); @@ -919,6 +919,10 @@ export function registerIpc(ipcMain: IpcRegistrar, { assertMainRenderer(event, getMainWindow); return githubAuth.signOut(); }); + ipcMain.handle(IPC.githubAuthCancel, (event) => { + assertMainRenderer(event, getMainWindow); + githubAuth.cancelDeviceFlow(); + }); ipcMain.handle(IPC.githubAuthOpenUrl, (event, value: unknown) => { assertMainRenderer(event, getMainWindow); if (typeof value !== "string") throw new Error("URL is required."); diff --git a/src/main/services/GithubAuthService.ts b/src/main/services/GithubAuthService.ts index defbdb6b7..3d416d1b2 100644 --- a/src/main/services/GithubAuthService.ts +++ b/src/main/services/GithubAuthService.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import * as electron from "electron"; import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises"; import { join, dirname } from "node:path"; +import type { GithubDeviceFlowState } from "../../shared/contracts"; /** GitHub OAuth Device Flow for the plugin showcase. */ @@ -10,6 +11,7 @@ export interface GithubAuthStatus { authorized: boolean; login: string | null; tokenExpiresAt: number | null; + deviceFlowState: GithubDeviceFlowState; } interface StoredTokens { @@ -60,6 +62,7 @@ export class GithubAuthService { private readonly pollTimeoutMs: number; private generation = 0; private deviceFlow: { generation: number; controller: AbortController } | null = null; + private deviceFlowState: GithubDeviceFlowState = "idle"; private storeWrite = Promise.resolve(); constructor(userDataPath: string, clientId?: string, options: GithubAuthServiceOptions = {}) { @@ -126,13 +129,20 @@ export class GithubAuthService { async status(): Promise { if (!this.tokens) { - return { configured: this.clientConfigured, authorized: false, login: null, tokenExpiresAt: null }; + return { + configured: this.clientConfigured, + authorized: false, + login: null, + tokenExpiresAt: null, + deviceFlowState: this.deviceFlowState + }; } return { configured: this.clientConfigured, authorized: true, login: this.tokens.login, - tokenExpiresAt: this.tokens.expiresAt + tokenExpiresAt: this.tokens.expiresAt, + deviceFlowState: this.deviceFlowState }; } @@ -146,10 +156,11 @@ export class GithubAuthService { throw new Error("GitHub OAuth is not configured (missing client id)."); } - this.cancelDeviceFlow(); + this.abortDeviceFlow(); const generation = ++this.generation; const controller = new AbortController(); this.deviceFlow = { generation, controller }; + this.deviceFlowState = "pending"; try { const body = new URLSearchParams({ client_id: this.clientId }); @@ -176,7 +187,9 @@ export class GithubAuthService { void this.pollDeviceCode(deviceCode, interval, flowLifetimeMs, generation, controller.signal) .catch((error) => { - if (!isAbortError(error)) console.warn("CanvasTTY GitHub OAuth device poll failed.", error); + if (controller.signal.aborted || isAbortError(error)) return; + this.finishDeviceFlow(generation, "failed"); + console.warn("CanvasTTY GitHub OAuth device poll failed.", error); }) .finally(() => { if (this.deviceFlow?.generation === generation) this.deviceFlow = null; @@ -184,14 +197,26 @@ export class GithubAuthService { return { userCode, verificationUri, interval, expiresAt: this.now() + flowLifetimeMs }; } catch (error) { - if (this.deviceFlow?.generation === generation) this.deviceFlow = null; + if (this.deviceFlow?.generation === generation) { + this.deviceFlow = null; + // The caller receives this error directly, so no separate outcome is kept. + this.deviceFlowState = "idle"; + } throw error; } } + /** Stops a pending device sign-in. A completed sign-in or an existing session is left untouched. */ + cancelDeviceFlow(): void { + if (!this.deviceFlow || this.deviceFlowState !== "pending") return; + this.abortDeviceFlow(); + this.deviceFlowState = "cancelled"; + } + async signOut(): Promise { this.generation += 1; - this.cancelDeviceFlow(); + this.abortDeviceFlow(); + this.deviceFlowState = "idle"; this.tokens = null; this.refreshPromise = null; await this.enqueueStoreWrite(async () => { @@ -199,9 +224,17 @@ export class GithubAuthService { }); } - private cancelDeviceFlow(): void { - this.deviceFlow?.controller.abort(); + private abortDeviceFlow(): void { + const flow = this.deviceFlow; + // Clear the flow before aborting so a fetcher that ignores its AbortSignal + // cannot apply a late response: every step re-checks isCurrentFlow(). this.deviceFlow = null; + flow?.controller.abort(); + } + + private finishDeviceFlow(generation: number, state: GithubDeviceFlowState): void { + if (this.deviceFlow?.generation !== generation || this.deviceFlowState !== "pending") return; + this.deviceFlowState = state; } private isCurrentFlow(generation: number, signal: AbortSignal): boolean { @@ -217,51 +250,82 @@ export class GithubAuthService { ): Promise { const started = this.now(); let interval = initialInterval; + let transientFailures = 0; + // A dropped connection, the request timeout, HTTP 429/5xx or an unreadable + // body is not the end of the flow: the person may still approve the code. + // Back off (RFC 8628 section 3.5) and poll again, but give up after a few + // failures in a row instead of polling a broken endpoint until the code expires. + const retryOrFail = (): boolean => { + transientFailures += 1; + if (transientFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { + this.finishDeviceFlow(generation, "failed"); + return false; + } + interval = backedOff(interval); + return true; + }; while (this.now() - started < lifetimeMs) { await this.wait(interval * 1000, signal); if (!this.isCurrentFlow(generation, signal)) return; + if (this.now() - started >= lifetimeMs) break; const body = new URLSearchParams({ client_id: this.clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }); - // A dropped connection, the 15 s request timeout or a GitHub 5xx is not - // the end of the flow: the person may still approve the code. Back off - // (RFC 8628 section 3.5) and poll again until the code expires. - let payload: unknown; + let response: Response; + let payload: unknown = null; try { - const response = await this.request("https://github.com/login/oauth/access_token", { + response = await this.request("https://github.com/login/oauth/access_token", { method: "POST", headers: oauthHeaders(), body: body.toString() }, signal); - if (!response.ok) { - interval = backedOff(interval); - continue; - } - payload = await response.json(); + // OAuth errors may arrive with HTTP 400 (RFC 6749 section 5.2), so read the body either way. + payload = await response.json().catch(() => null); } catch (error) { if (signal.aborted) throw error; - interval = backedOff(interval); - continue; + if (!this.isCurrentFlow(generation, signal)) return; + if (retryOrFail()) continue; + return; } - if (!isRecord(payload)) { - interval = backedOff(interval); - continue; + if (!this.isCurrentFlow(generation, signal)) return; + const providerError = isRecord(payload) && typeof payload.error === "string" ? payload.error : null; + if (providerError === null && (!response.ok || !isRecord(payload))) { + if (isTransientStatus(response.status) || response.ok) { + if (retryOrFail()) continue; + return; + } + this.finishDeviceFlow(generation, "failed"); + return; } - if (payload.error === "authorization_pending" || payload.error === "slow_down") { - if (payload.error === "slow_down") { + if (providerError === "authorization_pending" || providerError === "slow_down") { + transientFailures = 0; + if (providerError === "slow_down") { // +5 s for this and later polls; GitHub may name a longer interval. - const requested = typeof payload.interval === "number" && Number.isFinite(payload.interval) ? payload.interval : 0; + const requested = isRecord(payload) && typeof payload.interval === "number" && Number.isFinite(payload.interval) + ? payload.interval + : 0; interval = Math.max(interval + 5, Math.min(requested, MAX_POLL_INTERVAL_SECONDS)); } continue; } - if (payload.error === "access_denied" || payload.error === "expired_token") return; - if (typeof payload.access_token === "string") { + if (providerError === "access_denied") { + this.finishDeviceFlow(generation, "denied"); + return; + } + if (providerError === "expired_token") { + this.finishDeviceFlow(generation, "expired"); + return; + } + if (providerError === null && isRecord(payload) && typeof payload.access_token === "string") { const login = await this.fetchLogin(payload.access_token, signal); - if (!login || !this.isCurrentFlow(generation, signal)) return; + if (!this.isCurrentFlow(generation, signal)) return; + if (!login) { + this.finishDeviceFlow(generation, "failed"); + return; + } const expiresIn = typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : null; @@ -272,11 +336,15 @@ export class GithubAuthService { login }; this.tokens = next; + this.deviceFlowState = "idle"; await this.persist(next, generation); return; } + // Any other provider error (device_flow_disabled, incorrect_client_credentials, ...) is final. + this.finishDeviceFlow(generation, "failed"); return; } + this.finishDeviceFlow(generation, "expired"); } private async refreshAccessToken(expected: StoredTokens, generation: number): Promise { @@ -433,6 +501,12 @@ function isMissingFile(error: unknown): boolean { } const MAX_POLL_INTERVAL_SECONDS = 60; +/** Consecutive network, timeout, HTTP 429/5xx or unreadable answers before the sign-in is reported as failed. */ +const MAX_CONSECUTIVE_POLL_FAILURES = 5; + +function isTransientStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} function backedOff(interval: number): number { return Math.min(Math.max(interval * 2, interval + 1), MAX_POLL_INTERVAL_SECONDS); diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 0e86e7192..48de6a13b 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -1501,7 +1501,7 @@ function repositoryKeyOfUrl(value: string): string { export function validatePluginManifest(candidate: unknown): PluginManifest { if (!isRecord(candidate)) throw new Error("Plugin manifest must be a JSON object."); assertOnlyKeys(candidate, [ - "apiVersion", "id", "name", "version", "description", "description.ru", "description.en", + "apiVersion", "id", "name", "name.ru", "name.en", "version", "description", "description.ru", "description.en", "icon", "author", "homepage", "settingsContribution", "coreFiles", "modules", "permissions", "contributions", "hooks", "services", "platforms", "minHostVersion" ], "Plugin manifest"); @@ -1515,6 +1515,8 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { const id = requiredString(candidate.id, "id", 80); if (!isPluginId(id) || id === "host") throw new Error("Plugin id must be a lowercase DNS-style identifier."); const name = requiredString(candidate.name, "name", 80); + const nameRu = optionalString(candidate["name.ru"], "name.ru", 80); + const nameEn = optionalString(candidate["name.en"], "name.en", 80); const version = requiredString(candidate.version, "version", 40); if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error("Plugin version must be semantic version text."); const description = requiredString(candidate.description, "description", 2_000); @@ -1626,6 +1628,8 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { apiVersion, id, name, + ...(nameRu ? { "name.ru": nameRu } : {}), + ...(nameEn ? { "name.en": nameEn } : {}), version, description, ...(descriptionRu ? { "description.ru": descriptionRu } : {}), @@ -1947,20 +1951,26 @@ function validateLaunchFields(value: unknown): PluginLaunchField[] { const keys = new Set(); return value.map((field): PluginLaunchField => { if (!isRecord(field)) throw new Error("Every plugin launch field must be an object."); - assertOnlyKeys(field, ["key", "label", "kind", "options", "optionsFrom", "default", "maxLength"], "Plugin launch field"); + assertOnlyKeys(field, ["key", "label", "label.ru", "label.en", "kind", "options", "optionsFrom", "default", "maxLength"], "Plugin launch field"); const key = requiredString(field.key, "launch field key", 40); if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key) || keys.has(key)) { throw new Error(`Plugin launch field key is invalid or duplicated: ${key}.`); } keys.add(key); - const label = requiredString(field.label, "launch field label", 80); + const labelRu = optionalString(field["label.ru"], "launch field label.ru", 80); + const labelEn = optionalString(field["label.en"], "launch field label.en", 80); + const label = { + label: requiredString(field.label, "launch field label", 80), + ...(labelRu ? { "label.ru": labelRu } : {}), + ...(labelEn ? { "label.en": labelEn } : {}) + }; if (field.optionsFrom !== undefined && (field.kind !== "select" || field.optionsFrom !== "service")) { throw new Error(`Plugin launch field ${key} optionsFrom must be "service" on a select.`); } if (field.kind === "boolean") { if (field.options !== undefined || field.maxLength !== undefined) throw new Error(`Plugin launch field ${key} has keys its kind does not use.`); if (field.default !== undefined && typeof field.default !== "boolean") throw new Error(`Plugin launch field ${key} default must be a boolean.`); - return { key, label, kind: "boolean", ...(field.default !== undefined ? { default: field.default } : {}) }; + return { key, ...label, kind: "boolean", ...(field.default !== undefined ? { default: field.default } : {}) }; } if (field.kind === "select") { if (field.maxLength !== undefined) throw new Error(`Plugin launch field ${key} has keys its kind does not use.`); @@ -1980,7 +1990,7 @@ function validateLaunchFields(value: unknown): PluginLaunchField[] { throw new Error(`Plugin launch field ${key} default must be one of its options.`); } return { - key, label, kind: "select", options, + key, ...label, kind: "select", options, ...(field.optionsFrom === "service" ? { optionsFrom: "service" as const } : {}), ...(field.default !== undefined ? { default: field.default as string } : {}) }; @@ -1995,7 +2005,7 @@ function validateLaunchFields(value: unknown): PluginLaunchField[] { throw new Error(`Plugin launch field ${key} default must be text within maxLength.`); } return { - key, label, kind: "text", + key, ...label, kind: "text", ...(field.maxLength !== undefined ? { maxLength: maxLength as number } : {}), ...(field.default !== undefined ? { default: field.default as string } : {}) }; @@ -2007,11 +2017,13 @@ function validateLaunchFields(value: unknown): PluginLaunchField[] { function validateContribution(value: unknown): PluginContribution { if (!isRecord(value)) throw new Error("Every plugin contribution must be an object."); assertOnlyKeys(value, [ - "id", "kind", "title", "description", "entry", "icon", "module", "defaultSize", "minSize" + "id", "kind", "title", "title.ru", "title.en", "description", "entry", "icon", "module", "defaultSize", "minSize" ], "Plugin contribution"); const id = requiredString(value.id, "contribution id", 64); if (!isContributionId(id)) throw new Error("Contribution id contains unsupported characters."); const title = requiredString(value.title, "contribution title", 80); + const titleRu = optionalString(value["title.ru"], "contribution title.ru", 80); + const titleEn = optionalString(value["title.en"], "contribution title.en", 80); const description = optionalString(value.description, "contribution description", 240); const entry = assetPath(requiredString(value.entry, "contribution entry", 180)); if (extname(entry).toLowerCase() !== ".html") throw new Error("Contribution entry must be an HTML file."); @@ -2022,6 +2034,8 @@ function validateContribution(value: unknown): PluginContribution { const base = { id, title, + ...(titleRu ? { "title.ru": titleRu } : {}), + ...(titleEn ? { "title.en": titleEn } : {}), ...(description ? { description } : {}), entry, ...(icon ? { icon } : {}), diff --git a/src/preload/index.ts b/src/preload/index.ts index 32f38cf65..dbde3cf91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -254,6 +254,7 @@ const api: CanvasTTYApi = { status: () => ipcRenderer.invoke(IPC.githubAuthStatus), start: () => ipcRenderer.invoke(IPC.githubAuthStart), signOut: () => ipcRenderer.invoke(IPC.githubAuthSignOut), + cancel: () => ipcRenderer.invoke(IPC.githubAuthCancel), openUrl: (url: string) => ipcRenderer.invoke(IPC.githubAuthOpenUrl, url) }, browser: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cb72f6485..eb2e1f510 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -50,6 +50,7 @@ import { keyboardPresetShortcuts } from "../../shared/contracts"; import { normalizeExternalUrl } from "../../shared/externalUrl"; +import { pluginName } from "../../shared/pluginLocalizedText.ts"; import { createTerminalBorderSkinStyleController, type TerminalBorderSkinStyleController @@ -1638,7 +1639,7 @@ export function App(): React.JSX.Element { homeLayout = [...homeLayout, result.placement]; } if (homeLayout !== settings.homeLayout) await saveSettings({ homeGridSize, homeLayout }); - showToast(`${t(settings.locale, "pluginInstalled")}: ${installed.manifest.name}`); + showToast(`${t(settings.locale, "pluginInstalled")}: ${pluginName(installed.manifest, settings.locale)}`); }, [saveSettings, settings.homeGridSize, settings.homeLayout, settings.locale, showToast]); const refreshPlugins = useCallback(async (): Promise => { @@ -1754,7 +1755,7 @@ export function App(): React.JSX.Element { throw error; } setPlugins((current) => current.map((plugin) => plugin.manifest.id === pluginId ? updated : plugin)); - showToast(`${t(settings.locale, "pluginUpdated")}: ${updated.manifest.name}`); + showToast(`${t(settings.locale, "pluginUpdated")}: ${pluginName(updated.manifest, settings.locale)}`); }, [refreshPlugins, settings.locale, showToast]); const openPluginCanvasContribution = useCallback(async ( diff --git a/src/renderer/src/components/ShortcutReference.tsx b/src/renderer/src/components/ShortcutReference.tsx index 2f736c8af..8752d086a 100644 --- a/src/renderer/src/components/ShortcutReference.tsx +++ b/src/renderer/src/components/ShortcutReference.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import type { AppSettings } from "../../../shared/contracts"; -import { displayCanvasNavigationBinding } from "../lib/shortcuts"; +import { displayCanvasNavigationBinding, displayShortcutBinding } from "../lib/shortcuts"; import { UiIcon } from "./UiIcon"; import { t, type TranslationKey } from "../lib/i18n"; @@ -11,9 +11,7 @@ export function ShortcutReference({ settings, onClose }: { const dialog = useRef(null); const { locale, shortcuts: bindings } = settings; const mac = window.canvasTTY.window.isMacOS; - const display = (binding: string) => binding.replace("Meta", mac ? "Command" : "Super") - .replace("Comma", ",").replace(/ArrowUp/g, "↑").replace(/ArrowDown/g, "↓") - .replace(/ArrowLeft/g, "←").replace(/ArrowRight/g, "→") || t(locale, "disabled"); + const display = (binding: string) => displayShortcutBinding(binding, mac) || t(locale, "disabled"); const groups: { title: TranslationKey; shortcuts: [string[], TranslationKey][] }[] = [ { title: "keyboardCanvas", shortcuts: [ [[bindings.home], "homeShortcut"], @@ -22,7 +20,7 @@ export function ShortcutReference({ settings, onClose }: { [[bindings.commandPalette], "commandPalette"], [[bindings.openSettings], "settings"], [[bindings.toggleDetail], "keyboardToggleDetail"], - [["Shift + drag"], "marqueeSelectionHint"] + [[t(locale, "shortcutShiftDrag")], "marqueeSelectionHint"] ] }, { title: "keyboardNavigation", shortcuts: [ [[bindings.focusUp], "keyboardFocusUp"], diff --git a/src/renderer/src/features/home/HomeAppearanceSettings.tsx b/src/renderer/src/features/home/HomeAppearanceSettings.tsx index d8c9e0948..3ebb7672f 100644 --- a/src/renderer/src/features/home/HomeAppearanceSettings.tsx +++ b/src/renderer/src/features/home/HomeAppearanceSettings.tsx @@ -43,7 +43,7 @@ export function HomeAppearanceSettings({ description: widget.description ? t(locale, widget.description) : undefined, pluginEnabled: true })), - ...pluginHomeWidgetOptions(plugins) + ...pluginHomeWidgetOptions(plugins, locale) ]; return ( diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index af9068e6a..c99fd20c8 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -387,6 +387,7 @@ export function HomeZone({ contribution={contribution} locale={locale} palette={settings.palette} + uiScale={settings.uiScale} sessions={sessions} limits={limits} captureCanvasWheelOverWidgets={captureCanvasWheelOverWidgets || focusedWidgetId !== canvasWidgetId} diff --git a/src/renderer/src/features/home/pluginHomeWidgets.ts b/src/renderer/src/features/home/pluginHomeWidgets.ts index 73376598c..202cad8d9 100644 --- a/src/renderer/src/features/home/pluginHomeWidgets.ts +++ b/src/renderer/src/features/home/pluginHomeWidgets.ts @@ -1,4 +1,5 @@ -import type { InstalledPlugin, PluginGridSize } from "../../../../shared/contracts"; +import type { InstalledPlugin, LocaleId, PluginGridSize } from "../../../../shared/contracts"; +import { contributionTitle, pluginName } from "../../../../shared/pluginLocalizedText.ts"; import { pluginWidgetId } from "./homeLayout.ts"; export interface PluginHomeWidgetOption { @@ -10,15 +11,15 @@ export interface PluginHomeWidgetOption { required?: false; } -export function pluginHomeWidgetOptions(plugins: readonly InstalledPlugin[]): PluginHomeWidgetOption[] { +export function pluginHomeWidgetOptions(plugins: readonly InstalledPlugin[], locale: LocaleId): PluginHomeWidgetOption[] { return plugins.flatMap((plugin) => plugin.manifest.contributions .filter((contribution) => contribution.kind === "home-widget") .map((contribution) => ({ widgetId: pluginWidgetId(plugin.manifest.id, contribution.id), - label: contribution.title, + label: contributionTitle(contribution, locale), description: contribution.description - ? `${plugin.manifest.name} · ${contribution.description}` - : plugin.manifest.name, + ? `${pluginName(plugin.manifest, locale)} · ${contribution.description}` + : pluginName(plugin.manifest, locale), size: contribution.defaultSize, pluginEnabled: plugin.enabled }))); diff --git a/src/renderer/src/features/launcher/LaunchOptionsSection.tsx b/src/renderer/src/features/launcher/LaunchOptionsSection.tsx index 46d5973e1..badde4191 100644 --- a/src/renderer/src/features/launcher/LaunchOptionsSection.tsx +++ b/src/renderer/src/features/launcher/LaunchOptionsSection.tsx @@ -11,16 +11,23 @@ import type { } from "../../../../shared/contracts"; import { t } from "../../lib/i18n"; import { withServiceOptions } from "./launchFieldOptions"; +import { launchFieldLabel, pluginName } from "../../../../shared/pluginLocalizedText.ts"; -interface LaunchOptionPlugin { +/** The plugin's name, with its `name.ru`/`name.en` for the UI language. */ +type PluginNames = Pick; + +function names(plugin: InstalledPlugin): PluginNames { + const { name, "name.ru": ru, "name.en": en } = plugin.manifest; + return { name, ...(ru ? { "name.ru": ru } : {}), ...(en ? { "name.en": en } : {}) }; +} + +interface LaunchOptionPlugin extends PluginNames { pluginId: string; - name: string; fields: PluginLaunchField[]; } -interface EnvironmentOption { +interface EnvironmentOption extends PluginNames { pluginId: string; - name: string; kind: PluginEnvironmentKind; } @@ -34,7 +41,7 @@ function launchOptionPlugins(plugins: readonly InstalledPlugin[], provider: Prov if (launch.appliesTo && !launch.appliesTo.includes(provider)) return []; // A policy-only contributor has nothing to choose: it is asked on every launch anyway. if (launch.policy && launch.fields.length === 0) return []; - return [{ pluginId: plugin.manifest.id, name: plugin.manifest.name, fields: launch.fields }]; + return [{ pluginId: plugin.manifest.id, ...names(plugin), fields: launch.fields }]; }).sort((left, right) => left.pluginId.localeCompare(right.pluginId)); } @@ -46,7 +53,7 @@ export function environmentOptions(plugins: readonly InstalledPlugin[], provider if (!plugin.manifest.permissions.includes("environment:provide")) return []; return kinds .filter((kind) => !kind.appliesTo || kind.appliesTo.includes(provider)) - .map((kind) => ({ pluginId: plugin.manifest.id, name: plugin.manifest.name, kind })); + .map((kind) => ({ pluginId: plugin.manifest.id, ...names(plugin), kind })); }).sort((left, right) => left.pluginId.localeCompare(right.pluginId)); } @@ -55,8 +62,9 @@ function defaults(fields: readonly PluginLaunchField[]): PluginLaunchValues { ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : "")])); } -function FieldInputs({ fields, values, onChange }: { +function FieldInputs({ fields, values, locale, onChange }: { fields: readonly PluginLaunchField[]; + locale: LocaleId; values: PluginLaunchValues; onChange(values: PluginLaunchValues): void; }): React.JSX.Element { @@ -68,7 +76,7 @@ function FieldInputs({ fields, values, onChange }: { onChange({ ...values, [field.key]: event.target.checked })} /> )} - {field.label} + {launchFieldLabel(field, locale)} {field.kind === "select" && ( {selected && where?.options && ( - setWhere({ ...where, options })} /> )} @@ -184,9 +192,9 @@ export function LaunchOptionsSection({ provider, locale, onChange, onEnvironment - {values && update(plugin.pluginId, next)} />} + {values && update(plugin.pluginId, next)} />} ); })} diff --git a/src/renderer/src/features/plugins/PluginCanvasCard.tsx b/src/renderer/src/features/plugins/PluginCanvasCard.tsx index 57303aba5..0a54522c2 100644 --- a/src/renderer/src/features/plugins/PluginCanvasCard.tsx +++ b/src/renderer/src/features/plugins/PluginCanvasCard.tsx @@ -17,6 +17,7 @@ import { t } from "../../lib/i18n"; import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; import { constrainMascotResize, constrainPluginResize, fitMascotBounds } from "./pluginBounds"; import { PluginFrame } from "./PluginFrame"; +import { contributionTitle, pluginName } from "../../../../shared/pluginLocalizedText.ts"; import type { PluginCanvasWheelInput } from "./pluginInputBridge"; import { pluginCanvasWidgetId } from "../workspace/canvasWidgetFocus"; import { surfaceIsLive, surfaceLifecycle, useSurfaceOffscreen, useWindowHidden } from "../workspace/surfaceLifecycle"; @@ -27,6 +28,8 @@ interface PluginCanvasCardProps { contribution: PluginCanvasAppContribution; locale: LocaleId; palette: PaletteId; + /** The root UI scale, passed on to the plugin page. */ + uiScale: number; /** The canvas camera: drags read its zoom when they move; rendering subscribes to what it needs. */ camera: CameraStore; stackIndex: number; @@ -70,6 +73,7 @@ export function PluginCanvasCard({ contribution, locale, palette, + uiScale, camera, stackIndex, snapEnabled, @@ -92,6 +96,9 @@ export function PluginCanvasCard({ const mascotDragState = useRef(null); const resizeState = useRef(null); const isMascot = plugin.sourceUrl.startsWith("mascot:"); + // The card was opened with the contribution's title; show it in the UI language (`title.ru`/`title.en`). + const title = instance.title === contribution.title ? contributionTitle(contribution, locale) : instance.title; + const name = pluginName(plugin.manifest, locale); const mascotAspectRatio = contribution.defaultSize.width / contribution.defaultSize.height; const initialBounds = isMascot ? fitMascotBounds({ position: instance.position, size: instance.size }, mascotAspectRatio) @@ -278,7 +285,7 @@ export function PluginCanvasCard({ onPointerCancel={endDrag} onLostPointerCapture={cancelDrag} > - {instance.title}{plugin.manifest.name} + {title}{name} @@ -290,6 +297,7 @@ export function PluginCanvasCard({ contribution={contribution} locale={locale} palette={palette} + uiScale={uiScale} sessions={sessions} limits={limits} captureCanvasWheelOverWidgets={captureCanvasWheelOverWidgets} @@ -306,9 +314,9 @@ export function PluginCanvasCard({ className="plugin-canvas-card__summary" type="button" onClick={() => onActivate(instance)} - aria-label={instance.title} + aria-label={title} > - {instance.title}{plugin.manifest.name} + {title}{name} {RESIZE_DIRECTIONS.map((direction) => (
void>>(); @@ -25,6 +26,8 @@ interface PluginFrameProps { contribution: PluginContribution; locale: LocaleId; palette: PaletteId; + /** The root UI scale (Settings → Appearance), the value of the app's `--ui-scale`; sent to the page with the locale. */ + uiScale: number; sessions: readonly SessionSnapshot[]; limits: LimitsSnapshot | null; canvasInstanceId?: string; @@ -61,6 +64,7 @@ export function PluginFrame({ contribution, locale, palette, + uiScale, sessions, limits, canvasInstanceId, @@ -101,8 +105,8 @@ export function PluginFrame({ kind: contribution.kind, title: contribution.title }, - appearance: { locale, palette } - }), [contribution.id, contribution.kind, contribution.title, locale, palette, plugin.manifest]); + appearance: { locale, palette, uiScale } + }), [contribution.id, contribution.kind, contribution.title, locale, palette, uiScale, plugin.manifest]); useEffect(() => { const receive = (event: MessageEvent): void => { @@ -225,7 +229,7 @@ export function PluginFrame({ ref={frame} className={className ? `plugin-frame ${className}` : "plugin-frame"} src={entryUrl} - title={`${plugin.manifest.name}: ${contribution.title}`} + title={`${pluginName(plugin.manifest, locale)}: ${contributionTitle(contribution, locale)}`} sandbox="allow-scripts" referrerPolicy="no-referrer" inert={suspended} diff --git a/src/renderer/src/features/plugins/PluginSettingsSection.tsx b/src/renderer/src/features/plugins/PluginSettingsSection.tsx index 666c01bc2..f0b161be1 100644 --- a/src/renderer/src/features/plugins/PluginSettingsSection.tsx +++ b/src/renderer/src/features/plugins/PluginSettingsSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { AppSettings, GithubAuthStatus, + GithubDeviceFlowState, GithubDeviceFlowStart, GithubPluginSearchResult, InstalledPlugin, @@ -24,6 +25,7 @@ import { unresolvedPageUrls } from "./pluginPagination"; import { compareSemver } from "../../../../shared/hostVersion"; +import { contributionTitle, pluginName } from "../../../../shared/pluginLocalizedText.ts"; import { UiIcon } from "../../components/UiIcon"; /** Plugin description limit when displayed (longer text is truncated). */ @@ -181,6 +183,7 @@ export function PluginSettingsSection({ const [githubCode, setGithubCode] = useState(null); const [githubBusy, setGithubBusy] = useState(false); const [codeCopied, setCodeCopied] = useState(false); + const githubFlowNotice = githubFlowNoticeKey(githubStatus?.deviceFlowState); const [showcasePreviews, setShowcasePreviews] = useState>({}); const [showcaseManifests, setShowcaseManifests] = useState>({}); const [selectedShowcase, setSelectedShowcase] = useState(null); @@ -197,28 +200,27 @@ export function PluginSettingsSection({ // The main process owns the device flow; this only reads its status, so it pauses while Settings is // closed and picks up (sign-in finished or code expired) as soon as Settings opens again. const stop = pollWhileOpen(open, githubCode.interval * 1000, async () => { - if (Date.now() >= githubCode.expiresAt) { - if (!cancelled) { - setGithubCode(null); - setError(t(locale, "githubAuthExpired")); - } - return STOP_POLLING; - } // A transient status read failure is retried on the next tick. const status = await window.canvasTTY.githubAuth.status(); if (cancelled) return STOP_POLLING; - setGithubStatus(status); - if (status.authorized) { + if (status.authorized || isGithubFlowOutcome(status.deviceFlowState)) { + setGithubStatus(status); + setGithubCode(null); + return STOP_POLLING; + } + if (Date.now() >= githubCode.expiresAt) { + setGithubStatus({ ...status, deviceFlowState: "expired" }); setGithubCode(null); return STOP_POLLING; } + setGithubStatus(status); return undefined; }, { immediate: false }); return () => { cancelled = true; stop(); }; - }, [githubCode, locale, open]); + }, [githubCode, open]); const inspect = async (): Promise => { if (busy || sourceUrl.trim().length === 0) return; @@ -365,10 +367,22 @@ export function PluginSettingsSection({ if (githubBusy) return; setGithubBusy(true); setError(null); + let flow: GithubDeviceFlowStart; + try { + flow = await window.canvasTTY.githubAuth.start(); + } catch (reason) { + // The IPC error is main-process text ("Error invoking remote method …", HTTP codes): it goes to the log, and the + // GitHub section shows its own localized line (not configured, or sign-in failed). + console.warn("GitHub sign-in could not start.", reason); + const status = await window.canvasTTY.githubAuth.status().catch(() => null); + setGithubStatus(status && !status.configured ? status + : { configured: true, authorized: false, login: null, tokenExpiresAt: null, ...status, deviceFlowState: "failed" }); + setGithubBusy(false); + return; + } try { - const flow = await window.canvasTTY.githubAuth.start(); setGithubCode(flow); - setGithubStatus({ configured: true, authorized: false, login: null, tokenExpiresAt: null }); + setGithubStatus({ configured: true, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: "pending" }); await openGithubAuthorization(flow, target); } catch (reason) { setError(errorMessage(reason, t(locale, "githubAuthNotConfigured"))); @@ -387,7 +401,8 @@ export function PluginSettingsSection({ configured: current?.configured ?? false, authorized: false, login: null, - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" })); setGithubCode(null); } finally { @@ -395,6 +410,24 @@ export function PluginSettingsSection({ } }; + const runGithubCancel = async (): Promise => { + if (githubBusy) return; + setGithubBusy(true); + setError(null); + try { + await window.canvasTTY.githubAuth.cancel(); + // The poll may have finished first; the status says which outcome won. + setGithubStatus(await window.canvasTTY.githubAuth.status()); + setGithubCode(null); + setCodeCopied(false); + } catch (reason) { + console.warn("GitHub sign-in could not be cancelled.", reason); + setError(t(locale, "githubAuthCancelFailed")); + } finally { + setGithubBusy(false); + } + }; + const selectShowcaseTile = (fullName: string): void => { if (busy) return; setSelectedShowcase(fullName); @@ -533,7 +566,7 @@ export function PluginSettingsSection({ icon={showcaseIcons[key]} /> - {manifest ? manifest.name : showcaseName(result.fullName)} + {manifest ? pluginName(manifest, locale) : showcaseName(result.fullName)} {showcaseAuthor(result.fullName)} {renderHostTag(result.minHostVersion)} @@ -558,7 +591,7 @@ export function PluginSettingsSection({ icon={showcaseIcons[selectedShowcase]} /> - {manifest ? manifest.name : showcaseName(result.fullName)} + {manifest ? pluginName(manifest, locale) : showcaseName(result.fullName)} {showcaseAuthor(result.fullName)} @@ -642,8 +675,8 @@ export function PluginSettingsSection({ {preview && (
- - {preview.manifest.name}v{preview.manifest.version} + + {pluginName(preview.manifest, locale)}v{preview.manifest.version}

{manifestDescription(preview.manifest, locale)}

@@ -685,9 +718,9 @@ export function PluginSettingsSection({
setExpandedInstalled((current) => ({ ...current, [plugin.manifest.id]: !current[plugin.manifest.id] }))}> - + - {plugin.manifest.name} + {pluginName(plugin.manifest, locale)} v{plugin.manifest.version} {update && ( @@ -789,7 +822,7 @@ export function PluginSettingsSection({ {plugin.manifest.contributions.map((contribution) => (
- {contribution.title} + {contributionTitle(contribution, locale)} {t(locale, contributionKindKey(contribution.kind))} {contribution.kind !== "home-widget" && ( @@ -833,6 +866,12 @@ export function PluginSettingsSection({ )} + {!githubAuthorized && !githubCode && githubFlowNotice && ( + // A cancelled sign-in is the person's own choice; a denied, expired or failed one is an error. + githubStatus?.deviceFlowState === "cancelled" + ?

{t(locale, githubFlowNotice)}

+ :

{t(locale, githubFlowNotice)}

+ )} {!githubAuthorized && !githubCode &&

{t(locale, "githubAuthOptional")}

} {githubStatus?.authorized ? (
@@ -882,6 +921,9 @@ export function PluginSettingsSection({ {t(locale, "githubAuthOpenExternal")}
+
) : githubStatus && !githubConfigured ? (

{t(locale, "githubAuthNotConfigured")}

@@ -1154,3 +1196,17 @@ function errorMessage(error: unknown, fallback: string): string { .replace(/[\u0000-\u001f\u007f]/g, " ") .slice(0, 320); } + +function isGithubFlowOutcome(state: GithubDeviceFlowState | undefined): boolean { + return state === "denied" || state === "expired" || state === "failed" || state === "cancelled"; +} + +function githubFlowNoticeKey(state: GithubDeviceFlowState | undefined): TranslationKey | null { + switch (state) { + case "denied": return "githubAuthDenied"; + case "expired": return "githubAuthExpired"; + case "failed": return "githubAuthFailed"; + case "cancelled": return "githubAuthCancelled"; + default: return null; + } +} diff --git a/src/renderer/src/features/settings/AgentHooksSettings.tsx b/src/renderer/src/features/settings/AgentHooksSettings.tsx index 8f1a1fc07..24fbcf04c 100644 --- a/src/renderer/src/features/settings/AgentHooksSettings.tsx +++ b/src/renderer/src/features/settings/AgentHooksSettings.tsx @@ -8,6 +8,7 @@ import type { } from "../../../../shared/contracts"; import { PROVIDERS } from "../../lib/providers"; import { t, type TranslationKey } from "../../lib/i18n"; +import { pluginName } from "../../../../shared/pluginLocalizedText.ts"; interface AgentHooksSettingsProps { settings: AppSettings; @@ -98,7 +99,7 @@ export function AgentHooksSettings({
{row.hook.title} - {row.plugin.manifest.name} + {pluginName(row.plugin.manifest, locale)} {row.hook.description && {row.hook.description}}
- {plugin.manifest.name} + {pluginName(plugin.manifest, locale)} {t(locale, "pluginNativeCodeServices")} )} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index b69e9190a..67a3d8949 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -226,6 +226,11 @@ const ru = { githubAuthCopied: "Код скопирован", githubAuthNotConfigured: "Подключение GitHub недоступно: в этой сборке не настроен OAuth Client ID.", githubAuthExpired: "Код авторизации GitHub истёк. Начните вход заново.", + githubAuthDenied: "Запрос на вход в GitHub отклонён. Можно попробовать снова.", + githubAuthFailed: "Не удалось завершить вход в GitHub. Можно попробовать снова.", + githubAuthCancelled: "Вход в GitHub отменён.", + githubAuthCancel: "Отменить вход", + githubAuthCancelFailed: "Не удалось отменить вход в GitHub. Подробности записаны в журнал.", githubAuthOptional: "Витрина и поиск работают без входа. Вход в GitHub необязателен: он не запрашивает дополнительного доступа и только повышает лимиты поиска GitHub. Токен хранится зашифрованным на этом устройстве; выйти или отозвать доступ можно в любой момент.", showcaseOpenBrowser: "Открыть в браузере", showcaseOpenCanvas: "Открыть в CanvasTTY", @@ -491,16 +496,17 @@ const ru = { zoomSensitivity: "Чувствительность зума", useScrollWheelToZoom: "Зум обычным скроллом", useScrollWheelToZoomDescription: "Если выключено, обычный скролл перемещает канвас, а Ctrl/Command + скролл и pinch масштабируют.", - canvasWheelCapture: "Перехват wheel/pinch над виджетами", - canvasWheelCaptureDescription: "Off оставляет обычный wheel/pinch focused input-виджету, включая live Browser page. On и активный бинд передают события канвасу; pinch и Ctrl/Command + скролл над Browser всегда масштабируют canvas. Клики и drag не перехватываются.", - canvasWheelOverride: "Комбинация wheel/pinch", - canvasWheelOverrideHint: "Wheel/pinch поверх виджетов", + canvasWheelCapture: "Перехват колеса и жеста масштаба над виджетами", + canvasWheelCaptureDescription: "«Выключено» оставляет обычную прокрутку колесом и жест масштаба виджету с фокусом ввода, включая открытую страницу браузера. «Включено», а также «По комбинации» при нажатой комбинации, передают их канвасу; жест масштаба и Ctrl/Command + прокрутка над браузером всегда масштабируют канвас. Клики и перетаскивание не перехватываются.", + canvasWheelCaptureKey: "По комбинации", + canvasWheelOverride: "Комбинация для колеса и жеста масштаба", + canvasWheelOverrideHint: "Колесо и жест масштаба поверх виджетов", canvasOverrideShortcutInvalid: "Нужен модификатор, комбинация с ним или Mouse3/Mouse4/Mouse5.", canvasNavigationOverride: "Перехват навигации канвасом", - canvasNavigationOverrideDescription: "Пока комбинация или кнопка мыши удерживается, wheel, pinch и drag над любым виджетом принадлежат канвасу. Drag колёсиком работает всегда.", + canvasNavigationOverrideDescription: "Пока комбинация или кнопка мыши удерживается, прокрутка колесом, жест масштаба и перетаскивание над любым виджетом принадлежат канвасу. Перетаскивание с нажатым колёсиком работает всегда.", canvasNavigationCaptureHint: "Удержите модификатор, нажмите комбинацию или Mouse3/Mouse4/Mouse5. Escape отменяет запись.", canvasNavigationOverrideHint: "Навигация поверх виджетов", - canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает drag.", + canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает перетаскивание.", disabled: "Отключено", terminalWheelDirection: "Направление колеса терминала", copyOnSelect: "Копировать при выделении", @@ -769,7 +775,8 @@ const ru = { terminalSearchClose: "Закрыть поиск", fitCanvas: "Показать всё", focusWindowHint: "Фокус на соседнее окно", - marqueeSelectionHint: "Shift + тянуть — выделить группу", + marqueeSelectionHint: "Выделить группу", + shortcutShiftDrag: "Shift + перетаскивание", remarkAnchorStep: "Замечание к шагу", remarkAnchorPage: "Замечание к странице", remarkAnchorTime: "Замечание ко времени", @@ -1257,6 +1264,11 @@ const en: Record = { githubAuthCopied: "Code copied", githubAuthNotConfigured: "GitHub connection is unavailable: this build has no OAuth Client ID.", githubAuthExpired: "The GitHub authorization code expired. Start sign-in again.", + githubAuthDenied: "The GitHub sign-in request was denied. You can try again.", + githubAuthFailed: "GitHub sign-in could not be completed. You can try again.", + githubAuthCancelled: "GitHub sign-in was cancelled.", + githubAuthCancel: "Cancel sign-in", + githubAuthCancelFailed: "GitHub sign-in could not be cancelled. Details are in the log.", githubAuthOptional: "The showcase and search work without an account. Signing in to GitHub is optional: it requests no extra access and only raises GitHub's search limits. The token is stored encrypted on this device; you can sign out or revoke access at any time.", showcaseOpenBrowser: "Open in browser", showcaseOpenCanvas: "Open in CanvasTTY", @@ -1524,6 +1536,7 @@ const en: Record = { useScrollWheelToZoomDescription: "When off, plain scrolling pans the canvas while Ctrl/Command + scroll and pinch zoom.", canvasWheelCapture: "Wheel/pinch capture over widgets", canvasWheelCaptureDescription: "Off leaves plain wheel/pinch with the focused input widget, including a live Browser page. On and an active binding route it to the canvas; Browser pinch and Ctrl/Command + scroll always zoom the canvas. Clicks and drag are not captured.", + canvasWheelCaptureKey: "Key", canvasWheelOverride: "Wheel/pinch shortcut", canvasWheelOverrideHint: "Wheel/pinch over widgets", canvasOverrideShortcutInvalid: "Use a modifier, a modifier-based chord, or Mouse3/Mouse4/Mouse5.", @@ -1800,7 +1813,8 @@ const en: Record = { terminalSearchClose: "Close search", fitCanvas: "Fit to content", focusWindowHint: "Focus the neighbouring window", - marqueeSelectionHint: "Shift + drag selects a group", + marqueeSelectionHint: "Select a group", + shortcutShiftDrag: "Shift + drag", remarkAnchorStep: "Remark on step", remarkAnchorPage: "Remark on page", remarkAnchorTime: "Remark on the time", diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index 0b037c770..c1044a5ff 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -123,6 +123,15 @@ export function handleMacNativeSelectAll(event: KeyboardEvent, isMacOS: boolean) return true; } +/** + * An action shortcut as the hints panel and the shortcut reference show it: arrows as glyphs, Meta as Command or + * Super. Key by key, so the Comma key is not looked for inside "Command". + */ +export function displayShortcutBinding(binding: string, isMacOS: boolean): string { + const names: Record = { Meta: isMacOS ? "Command" : "Super", Comma: ",", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" }; + return binding.split("+").map((key) => names[key] ?? key).join("+"); +} + export function displayCanvasNavigationBinding(binding: string, isMacOS: boolean): string { if (!isMacOS) return binding; return binding.split("+").map((part) => { diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22b..8e2abe703 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -324,11 +324,11 @@ button { border: 0; } .canvas-controls { height: 44px; display: flex; align-items: center; padding: 4px; border-radius: 12px; color: white; background: rgba(40,41,52,.94); box-shadow: var(--shadow-sm); pointer-events: auto; } .canvas-controls button { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; color: white; background: transparent; cursor: pointer; transition: background .15s ease; } .canvas-controls button:hover { background: rgba(255,255,255,.13); } -.shortcut-hints { min-width: 224px; padding: 9px 11px; display: grid; gap: 7px; border-radius: 12px; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: none; } -.shortcut-hints > div { display: grid; grid-template-columns: minmax(52px, auto) 1fr; align-items: center; gap: 10px; } -.shortcut-hints kbd { min-width: 52px; padding: 5px 7px; border: 1px solid rgba(255,255,255,.14); border-radius: 7px; color: white; background: rgba(255,255,255,.1); font: 800 11px var(--font-mono); text-align: center; } -.shortcut-hints span { color: rgba(255,255,255,.66); font-size: 11px; font-weight: 750; } -.shortcut-hints__more { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 0 2px; border-top: 1px solid rgba(255,255,255,.12); outline: 0; color: white; background: transparent; cursor: pointer; pointer-events: auto; font-size: 11px; font-weight: 750; } +.shortcut-hints { min-width: 20.36em; padding: .82em 1em; display: grid; gap: .64em; border-radius: 1.09em; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: none; font-size: calc(11px * var(--ui-scale, 1)); } +.shortcut-hints > div { display: grid; grid-template-columns: minmax(4.73em, auto) 1fr; align-items: center; gap: .91em; } +.shortcut-hints kbd { min-width: 4.73em; padding: .45em .64em; border: 1px solid rgba(255,255,255,.14); border-radius: .64em; color: white; background: rgba(255,255,255,.1); font: 800 1em var(--font-mono); text-align: center; } +.shortcut-hints span { color: rgba(255,255,255,.66); font-size: 1em; font-weight: 750; } +.shortcut-hints__more { display: flex; align-items: center; justify-content: space-between; gap: 1.09em; padding: .64em 0 .18em; border-top: 1px solid rgba(255,255,255,.12); outline: 0; color: white; background: transparent; cursor: pointer; pointer-events: auto; font-size: 1em; font-weight: 750; } .shortcut-hints__more:hover { color: var(--secondary); } .shortcut-hints__more:focus-visible { outline: 2px solid var(--secondary); outline-offset: 3px; border-radius: 3px; } .shortcut-reference-dialog { width: min(1200px, calc(100vw - 40px)); max-width: none; max-height: none; padding: 0; overflow: visible; border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); border-radius: var(--radius-xl); color: var(--text); background: var(--surface); box-shadow: var(--shadow-lg); font-size: clamp(10px, calc(1.5vh * var(--ui-scale)), calc(13px * var(--ui-scale))); } @@ -901,6 +901,8 @@ button { border: 0; } .segmented__button--active { color: var(--text-dark); background: var(--primary); } .segmented__button:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, currentColor 42%, transparent); } .setting-group__control > .segmented + .shortcut-editor { margin-top: .6em; } +/* The canvas navigation rows sit in the narrow control column: their (longer localized) labels wrap instead of being cut off. */ +.setting-group__control .shortcut-editor__row > span { overflow-wrap: break-word; text-overflow: clip; white-space: normal; line-height: 1.3; } .swatch-choices { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .4em; } .swatch-choices--four { grid-template-columns: repeat(4, minmax(0, 1fr)); } .swatch-choice { min-width: 0; min-height: 4.6em; padding: .48em; display: grid; grid-template-rows: 2em auto; align-items: center; gap: .45em; border: .08em solid transparent; border-radius: .72em; outline: 0; color: var(--text-muted-on-dark); background: var(--surface-soft); cursor: pointer; font-size: .76em; font-weight: 700; transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; } @@ -1061,34 +1063,34 @@ button { border: 0; } .home-grid-size strong { font: 850 12px var(--font-mono); } .plugin-settings { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; } .plugin-install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } -.plugin-install-row input { min-width: 0; height: 45px; padding: 0 13px; border: 1px solid rgba(45,45,56,.14); outline: 0; border-radius: 10px; color: var(--text-dark); background: rgba(255,255,255,.58); font: 700 12px var(--font-mono); user-select: text; } +.plugin-install-row input { min-width: 0; height: 45px; padding: 0 13px; border: 1px solid rgba(45,45,56,.14); outline: 0; border-radius: 10px; color: var(--text-dark); background: rgba(255,255,255,.58); font: 700 calc(12px * var(--ui-scale, 1)) var(--font-mono); user-select: text; } .plugin-install-row input:focus { border-color: var(--surface); box-shadow: 0 0 0 3px color-mix(in srgb, var(--surface) 15%, transparent); } -.plugin-install-row button, .plugin-preview__actions button, .installed-plugin__header button, .plugin-contribution button, .home-widget-list__row button { min-height: 36px; padding: 7px 11px; border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.56); cursor: pointer; font-size: 11px; font-weight: 850; } +.plugin-install-row button, .plugin-preview__actions button, .installed-plugin__header button, .plugin-contribution button, .home-widget-list__row button { min-height: 36px; padding: 7px 11px; border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.56); cursor: pointer; font-size: calc(11px * var(--ui-scale, 1)); font-weight: 850; } .plugin-install-row button:disabled, .plugin-preview__actions button:disabled, .installed-plugin__header button:disabled, .plugin-contribution button:disabled, .home-widget-list__row button:disabled { cursor: not-allowed; opacity: .5; } .plugin-preview { margin-top: 9px; padding: 14px; border-radius: 13px; color: white; background: var(--surface); } .plugin-preview header { display: flex; align-items: center; justify-content: flex-start; gap: 12px; } .plugin-preview header > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.plugin-preview header strong { font-size: 17px; } -.plugin-preview header span { color: var(--text-muted-on-dark); font: 750 11px var(--font-mono); } -.plugin-preview p, .installed-plugin > p { margin: 8px 0 11px; font-size: 12px; line-height: 1.45; opacity: .78; } +.plugin-preview header strong { font-size: calc(17px * var(--ui-scale, 1)); } +.plugin-preview header span { color: var(--text-muted-on-dark); font: 750 calc(11px * var(--ui-scale, 1)) var(--font-mono); } +.plugin-preview p, .installed-plugin > p { margin: 8px 0 11px; font-size: calc(12px * var(--ui-scale, 1)); line-height: 1.45; opacity: .78; } .plugin-preview__actions { margin-top: 13px; display: flex; justify-content: flex-end; gap: 7px; } .plugin-preview__actions .plugin-primary-action { color: var(--text-dark); background: var(--primary); } -.plugin-permissions { display: grid; gap: 6px; font-size: 11px; line-height: 1.35; } +.plugin-permissions { display: grid; gap: 6px; font-size: calc(11px * var(--ui-scale, 1)); line-height: 1.35; } .plugin-permissions > strong { opacity: .72; } .plugin-permissions > span, .plugin-permissions li { opacity: .68; } .plugin-permissions ul { margin: 0; padding-left: 18px; display: grid; gap: 3px; } -.plugin-hook-warning { margin-top: 9px; padding: 10px 11px; display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(239,208,132,.35); border-radius: 10px; color: #ffe7a9; background: rgba(96,62,34,.48); font-size: 10px; line-height: 1.4; } -.plugin-hook-warning strong { font-size: 10px; } +.plugin-hook-warning { margin-top: 9px; padding: 10px 11px; display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(239,208,132,.35); border-radius: 10px; color: #ffe7a9; background: rgba(96,62,34,.48); font-size: calc(10px * var(--ui-scale, 1)); line-height: 1.4; } +.plugin-hook-warning strong { font-size: calc(10px * var(--ui-scale, 1)); } .plugin-hook-warning span { opacity: .82; } .plugin-hook-warning ul { margin: 4px 0 0; padding: 0; display: grid; gap: 4px; list-style: none; } .plugin-hook-warning li { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } -.plugin-hook-warning code { overflow-wrap: anywhere; font: 750 9px var(--font-mono); opacity: .82; text-align: right; } +.plugin-hook-warning code { overflow-wrap: anywhere; font: 750 calc(9px * var(--ui-scale, 1)) var(--font-mono); opacity: .82; text-align: right; } .plugin-modules { margin: 10px 0 0; padding: 9px 10px; display: grid; gap: 7px; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; } -.plugin-modules legend { padding: 0 5px; font-size: 11px; font-weight: 850; opacity: .72; } +.plugin-modules legend { padding: 0 5px; font-size: calc(11px * var(--ui-scale, 1)); font-weight: 850; opacity: .72; } .plugin-modules label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; } .plugin-modules label > span { min-width: 0; display: grid; flex: 1; gap: 2px; } -.plugin-modules label strong { font-size: 12px; } -.plugin-modules label small { font-size: 10px; line-height: 1.3; opacity: .68; } +.plugin-modules label strong { font-size: calc(12px * var(--ui-scale, 1)); } +.plugin-modules label small { font-size: calc(10px * var(--ui-scale, 1)); line-height: 1.3; opacity: .68; } .plugin-settings__error { margin: 9px 0 0; color: #8f3939; font-size: 12px; font-weight: 800; line-height: 1.4; } .plugin-settings__empty { margin: 0; padding: 17px; border-radius: 12px; background: rgba(255,255,255,.34); font-size: 12px; text-align: center; opacity: .65; } .plugin-icon { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 8px; object-fit: cover; background: rgba(255,255,255,.12); } @@ -1192,13 +1194,13 @@ button { border: 0; } .installed-plugin__details .plugin-modules { margin: 8px 0; } .installed-plugin__header > span.installed-plugin__title { min-width: 0; display: flex; flex-direction: row; align-items: center; gap: 10px; } .installed-plugin__header > span.installed-plugin__title > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.installed-plugin__header > span strong { overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; } -.installed-plugin__header > span small { color: var(--text-muted-on-dark); font: 700 10px var(--font-mono); display: flex; align-items: baseline; gap: 5px; } -.installed-plugin__version-arrow { color: var(--primary); font-size: 11px; } +.installed-plugin__header > span strong { overflow: hidden; font-size: calc(16px * var(--ui-scale, 1)); text-overflow: ellipsis; white-space: nowrap; } +.installed-plugin__header > span small { color: var(--text-muted-on-dark); font: 700 calc(10px * var(--ui-scale, 1)) var(--font-mono); display: flex; align-items: baseline; gap: 5px; } +.installed-plugin__version-arrow { color: var(--primary); font-size: calc(11px * var(--ui-scale, 1)); } .installed-plugin__version-new { color: var(--primary); } -.installed-plugin__host { display: block; margin-top: 2px; font: 700 10px var(--font-mono); } +.installed-plugin__host { display: block; margin-top: 2px; font: 700 calc(10px * var(--ui-scale, 1)) var(--font-mono); } .installed-plugin__author-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; } -.installed-plugin__author { color: var(--text-muted-on-dark); font-size: 14px; font-weight: 750; opacity: .85; } +.installed-plugin__author { color: var(--text-muted-on-dark); font-size: calc(14px * var(--ui-scale, 1)); font-weight: 750; opacity: .85; } .installed-plugin__repo-actions { display: inline-flex; gap: 6px; } .installed-plugin__header .installed-plugin__host, .plugin-showcase-tile__host { font-weight: 700; } .installed-plugin__header > div { display: flex; gap: 6px; } @@ -1209,8 +1211,8 @@ button { border: 0; } .plugin-contribution-list { margin-top: 10px; display: grid; gap: 6px; } .plugin-contribution { min-height: 51px; padding: 7px 7px 7px 11px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; border-radius: 10px; background: var(--surface-soft); } .plugin-contribution > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.plugin-contribution > span strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.plugin-contribution > span small { color: var(--text-muted-on-dark); font-size: 10px; } +.plugin-contribution > span strong { overflow: hidden; font-size: calc(12px * var(--ui-scale, 1)); text-overflow: ellipsis; white-space: nowrap; } +.plugin-contribution > span small { color: var(--text-muted-on-dark); font-size: calc(10px * var(--ui-scale, 1)); } /* Settings v3: iteration 7 two-pane structure with iteration 9 token roles. */ .settings-panel .setting-group:not(.setting-group--field) { padding: 1.1em 0; border-bottom: .08em solid color-mix(in srgb, var(--text) 9%, transparent); } @@ -1368,9 +1370,9 @@ button { border: 0; } .terminal-card__search input:focus { border-color: var(--primary); } .terminal-card__search button { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search button:hover { color: white; background: rgba(255,255,255,.16); } -.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + 6px); right: 10px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 9px; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 700; } -.terminal-card__menu label { display: flex; align-items: center; gap: 8px; padding: 4px 2px; cursor: pointer; } -.terminal-card__menu-action { display: block; width: 100%; padding: 5px 4px; border-radius: 6px; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } +.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + .5em); right: .84em; padding: .5em .67em; border: 1px solid rgba(255,255,255,.14); border-radius: .75em; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: calc(12px * var(--ui-scale, 1)); font-weight: 700; } +.terminal-card__menu label { display: flex; align-items: center; gap: .67em; padding: .33em .17em; cursor: pointer; } +.terminal-card__menu-action { display: block; width: 100%; padding: .42em .33em; border-radius: .5em; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } .terminal-card__menu-action:hover, .terminal-card__menu-action:focus-visible { background: rgba(255,255,255,.1); } .terminal-card__plugin-badge { flex: 0 0 auto; min-width: 0; max-width: 14em; overflow: hidden; padding: .15em .55em; border-radius: .5em; background: rgba(255,255,255,.14); font: 700 .7em var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .terminal-card__plugin-badge--info { background: color-mix(in srgb, #5b9cf5 38%, transparent); } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 12db40b04..2c4f856f1 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -817,6 +817,9 @@ export interface PluginGridSize extends HomeGridSize {} export interface PluginContributionBase { id: string; title: string; + /** The title when the app is in Russian / English (like the manifest's `description.ru`); `title` otherwise. */ + "title.ru"?: string; + "title.en"?: string; description?: string; entry: string; icon?: string; @@ -1004,6 +1007,9 @@ export type PluginLaunchFieldKind = "boolean" | "select" | "text"; export interface PluginLaunchField { key: string; label: string; + /** The label when the app is in Russian / English (like the manifest's `description.ru`); `label` otherwise. */ + "label.ru"?: string; + "label.en"?: string; kind: PluginLaunchFieldKind; /** `select` only: 1 to 16 choices. */ options?: Array<{ value: string; label: string }>; @@ -1090,6 +1096,9 @@ export interface PluginManifest { apiVersion: PluginApiVersion; id: string; name: string; + /** The name when the app is in Russian / English (like `description.ru`); `name` otherwise. */ + "name.ru"?: string; + "name.en"?: string; version: string; description: string; /** Optional icon path inside the package root (defaults to `icon.png`). */ @@ -1136,8 +1145,12 @@ export interface GithubAuthStatus { authorized: boolean; login: string | null; tokenExpiresAt: number | null; + /** Outcome of the most recent device sign-in; "idle" when none is pending or to report. */ + deviceFlowState: GithubDeviceFlowState; } +export type GithubDeviceFlowState = "idle" | "pending" | "denied" | "expired" | "failed" | "cancelled"; + export interface GithubDeviceFlowStart { userCode: string; verificationUri: string; @@ -2048,6 +2061,7 @@ export interface CanvasTTYApi { githubAuth: { status(): Promise; start(): Promise; + cancel(): Promise; signOut(): Promise; openUrl(url: string): Promise; }; @@ -2244,6 +2258,7 @@ export const IPC = { githubAuthStatus: "github-auth:status", githubAuthStart: "github-auth:start", githubAuthSignOut: "github-auth:sign-out", + githubAuthCancel: "github-auth:cancel", githubAuthOpenUrl: "github-auth:open-url", terminalList: "terminal:list", terminalOpenFile: "terminal:open-file", diff --git a/src/shared/pluginLocalizedText.ts b/src/shared/pluginLocalizedText.ts new file mode 100644 index 000000000..457cea042 --- /dev/null +++ b/src/shared/pluginLocalizedText.ts @@ -0,0 +1,22 @@ +import type { LocaleId, PluginContributionBase, PluginLaunchField, PluginManifest } from "./contracts.ts"; + +// A manifest text may come with a ".ru" and ".en" variant, like `description.ru`/`description.en`: the app shows the +// one for its language, and the plain text otherwise. +function localized(entry: { readonly [key: string]: string | undefined }, key: string, locale: LocaleId): string { + return entry[`${key}.${locale}`] || entry[key] || ""; +} + +/** The plugin's name in the UI language (`name.ru`/`name.en`); `name` otherwise. */ +export function pluginName(manifest: Pick, locale: LocaleId): string { + return localized(manifest, "name", locale); +} + +/** A contribution's (canvas app, settings page, home widget, window) title in the UI language; `title` otherwise. */ +export function contributionTitle(contribution: Pick, locale: LocaleId): string { + return localized(contribution, "title", locale); +} + +/** A launch field's label in the UI language (`label.ru`/`label.en`); `label` otherwise. */ +export function launchFieldLabel(field: Pick, locale: LocaleId): string { + return localized(field, "label", locale); +} diff --git a/tests/edit-context-menu.test.mjs b/tests/edit-context-menu.test.mjs index b58e6f957..3b80d572c 100644 --- a/tests/edit-context-menu.test.mjs +++ b/tests/edit-context-menu.test.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { runInNewContext } from "node:vm"; import test from "node:test"; import { attachEditContextMenu, editContextMenuTemplate } from "../src/main/editContextMenu.ts"; +import { contributionTitle } from "../src/shared/pluginLocalizedText.ts"; const flags = { canCut: true, canCopy: true, canPaste: true, canSelectAll: true, canUndo: false, canRedo: false, canDelete: true, canEditRichly: false }; @@ -72,6 +73,7 @@ test("a standalone plugin window gets its native edit menu before loading and us entryUrl: () => "canvastty-plugin://test/window.html" }, attachEditContextMenu, + contributionTitle, editMenuLocale: () => locale, Menu: { buildFromTemplate: template => ({ popup: options => shown.push({ template, window: options.window }) }) } }); diff --git a/tests/github-auth-integration.test.mjs b/tests/github-auth-integration.test.mjs index 7035b7eea..e94570198 100644 --- a/tests/github-auth-integration.test.mjs +++ b/tests/github-auth-integration.test.mjs @@ -9,6 +9,8 @@ const settingsPath = new URL( "../src/renderer/src/features/plugins/PluginSettingsSection.tsx", import.meta.url ); +const ipcPath = new URL("../src/main/ipc/registerIpc.ts", import.meta.url); +const preloadPath = new URL("../src/preload/index.ts", import.meta.url); test("GitHub sign-in supports optional build input and both browser routes", async () => { const [config, main, releaseWorkflow, settings] = await Promise.all([ @@ -31,3 +33,19 @@ test("GitHub sign-in supports optional build input and both browser routes", asy assert.match(settings, /else await window\.canvasTTY\.githubAuth\.openUrl\(url\)/); assert.match(settings, /const url = flow\.verificationUri/); }); + +test("a pending GitHub sign-in can be cancelled through the trusted renderer only", async () => { + const [ipc, preload, settings] = await Promise.all([ + readFile(ipcPath, "utf8"), + readFile(preloadPath, "utf8"), + readFile(settingsPath, "utf8") + ]); + const start = ipc.indexOf("ipcMain.handle(IPC.githubAuthCancel,"); + assert.notEqual(start, -1); + const handler = ipc.slice(start, ipc.indexOf("ipcMain.", start + 1)); + assert.match(handler, /assertMainRenderer\(event, getMainWindow\)/); + assert.match(handler, /githubAuth\.cancelDeviceFlow\(\)/); + assert.match(preload, /cancel: \(\) => ipcRenderer\.invoke\(IPC\.githubAuthCancel\)/); + assert.match(settings, /window\.canvasTTY\.githubAuth\.cancel\(\)/); + assert.match(settings, /t\(locale, "githubAuthCancel"\)/); +}); diff --git a/tests/github-auth.test.mjs b/tests/github-auth.test.mjs index 793039552..e5dae66b1 100644 --- a/tests/github-auth.test.mjs +++ b/tests/github-auth.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import test from "node:test"; import { GithubAuthService } from "../src/main/services/GithubAuthService.ts"; @@ -63,7 +63,8 @@ test("GitHub device flow persists an encrypted session atomically without reques configured: true, authorized: true, login: "howdeploy", - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" }); assert.equal(await restored.getToken(), "access"); await service.signOut(); @@ -157,7 +158,8 @@ test("signOut cancels an in-flight device flow and prevents a late token from be configured: true, authorized: false, login: null, - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" }); await assert.rejects(() => readFile(`${userData}/github-oauth.json`, "utf8"), { code: "ENOENT" }); } finally { @@ -211,7 +213,8 @@ test("reports an unconfigured OAuth client before starting device flow", async ( configured: false, authorized: false, login: null, - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" }); await assert.rejects( () => service.startDeviceFlow(), @@ -271,3 +274,213 @@ test("device polling survives network errors and timeouts, backs off, and honour await rm(userData, { recursive: true, force: true }); } }); + +const deviceCodeAnswer = () => Response.json({ + device_code: "test-device-code", + user_code: "TEST-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 1 +}); + +function serviceWith(userData, pollAnswer, options = {}) { + const requests = { polls: 0, profile: 0 }; + const fetcher = async (url, init = {}) => { + if (String(url).endsWith("/login/device/code")) return deviceCodeAnswer(); + if (String(url).endsWith("/login/oauth/access_token")) { + requests.polls += 1; + return pollAnswer(requests.polls, init); + } + if (String(url) === "https://api.github.com/user") { + requests.profile += 1; + return Response.json({ login: "fixture-user" }); + } + return new Response("missing", { status: 404 }); + }; + const service = new GithubAuthService(userData, "client-id", { + fetcher, + safeStorage, + delay: async () => undefined, + ...options + }); + return { service, requests }; +} + +test("access_denied, expired_token and unknown provider errors stop polling with a reported outcome", async () => { + for (const [answer, expected] of [ + [() => Response.json({ error: "access_denied" }), "denied"], + [() => Response.json({ error: "expired_token" }), "expired"], + [() => Response.json({ error: "access_denied" }, { status: 400 }), "denied"], + [() => Response.json({ error: "device_flow_disabled" }), "failed"], + [() => Response.json({ error: "incorrect_client_credentials" }, { status: 400 }), "failed"], + [() => new Response("forbidden", { status: 403 }), "failed"] + ]) { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-${expected}-`); + try { + const { service, requests } = serviceWith(userData, answer); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + const status = await service.status(); + assert.equal(status.authorized, false); + assert.equal(status.deviceFlowState, expected); + // Give a runaway poll loop a chance to show itself. + for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setImmediate(resolve)); + assert.equal(requests.polls, 1, `${expected}: polling stopped after the terminal answer`); + assert.equal(requests.profile, 0); + await assert.rejects(() => readFile(`${userData}/github-oauth.json`, "utf8"), { code: "ENOENT" }); + } finally { + await rm(userData, { recursive: true, force: true }); + } + } +}); + +test("cancelDeviceFlow stops polling, reports cancelled and keeps an existing session", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-cancel-flow-`); + const existing = { accessToken: "existing-fx", refreshToken: null, expiresAt: null, login: "existing-user" }; + const storedFile = JSON.stringify({ data: Buffer.from(JSON.stringify(existing)).toString("base64") }); + await writeFile(`${userData}/github-oauth.json`, storedFile); + const pollSignals = []; + const releases = []; + try { + const { service, requests } = serviceWith( + userData, + // The fetcher ignores its AbortSignal on purpose: a late answer must still be discarded. + () => new Promise((resolve) => { releases.push(resolve); }), + { + delay: (_duration, signal) => { + pollSignals.push(signal); + return signal.aborted ? Promise.reject(new DOMException("cancelled", "AbortError")) : Promise.resolve(); + } + } + ); + await service.load(); + await service.startDeviceFlow(); + await waitFor(() => releases.length === 1); + assert.equal((await service.status()).deviceFlowState, "pending"); + + service.cancelDeviceFlow(); + assert.equal(pollSignals[0].aborted, true); + assert.equal((await service.status()).deviceFlowState, "cancelled"); + + releases[0](Response.json({ access_token: "late-fx", token_type: "bearer", scope: "" })); + for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setImmediate(resolve)); + + const status = await service.status(); + assert.equal(status.deviceFlowState, "cancelled"); + assert.equal(status.login, "existing-user"); + assert.equal(requests.polls, 1, "no poll after cancel"); + assert.equal(requests.profile, 0, "the late token was not used"); + assert.equal(await service.getToken(), "existing-fx"); + assert.equal(await readFile(`${userData}/github-oauth.json`, "utf8"), storedFile); + + // Cancelling again, or with nothing pending, changes nothing. + service.cancelDeviceFlow(); + assert.equal((await service.status()).deviceFlowState, "cancelled"); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("cancelDeviceFlow after a completed sign-in keeps it", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-cancel-done-`); + try { + const { service } = serviceWith(userData, () => Response.json({ access_token: "done-fx", token_type: "bearer", scope: "" })); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).authorized); + const completed = await service.status(); + assert.equal(completed.deviceFlowState, "idle"); + service.cancelDeviceFlow(); + assert.deepEqual(await service.status(), completed); + assert.equal(await service.getToken(), "done-fx"); + await service.signOut(); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("slow_down adds five seconds to this and every later poll", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-slow-down-`); + const waits = []; + const answers = [ + { error: "authorization_pending" }, + { error: "slow_down" }, + { error: "authorization_pending" }, + { error: "slow_down" }, + { error: "authorization_pending" }, + { access_token: "slow-fx", token_type: "bearer", scope: "" } + ]; + try { + const { service } = serviceWith(userData, () => Response.json(answers.shift()), { + delay: async (ms) => { waits.push(ms); } + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).authorized); + assert.deepEqual(waits, [1_000, 1_000, 6_000, 6_000, 11_000, 11_000]); + await service.signOut(); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("transient poll errors retry with back-off, reset on a valid answer, and give up after a bounded run", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-bounded-`); + let clock = 1_000_000; + const waits = []; + const transient = [ + () => { throw new TypeError("fetch failed"); }, + () => new Response("unavailable", { status: 503 }), + () => new Response("too many", { status: 429 }), + () => new Response("not json", { status: 200 }), + () => new Response("bad gateway", { status: 502 }) + ]; + // Four failures, one pending answer (resets the count), then five failures in a row. + const answers = [...transient.slice(0, 4), () => Response.json({ error: "authorization_pending" }), ...transient]; + const warn = console.warn; + console.warn = () => undefined; + try { + const { service, requests } = serviceWith(userData, () => answers.shift()(), { + now: () => clock, + delay: async (ms) => { waits.push(ms); clock += ms; } + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + assert.equal((await service.status()).deviceFlowState, "failed"); + assert.equal(requests.polls, 10); + assert.equal(answers.length, 0); + assert.deepEqual(waits, [1_000, 2_000, 4_000, 8_000, 16_000, 16_000, 32_000, 60_000, 60_000, 60_000]); + } finally { + console.warn = warn; + await rm(userData, { recursive: true, force: true }); + } +}); + +test("the device code expires on the host clock even while GitHub keeps answering pending", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-elapsed-`); + let clock = 1_000_000; + try { + const { service, requests } = serviceWith(userData, () => Response.json({ error: "authorization_pending" }), { + now: () => clock, + pollTimeoutMs: 2_500, + delay: async (ms) => { clock += ms; } + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + assert.equal((await service.status()).deviceFlowState, "expired"); + assert.equal(requests.polls, 2); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("signOut clears a reported sign-in outcome", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-signout-outcome-`); + try { + const { service } = serviceWith(userData, () => Response.json({ error: "access_denied" })); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState === "denied"); + await service.signOut(); + assert.equal((await service.status()).deviceFlowState, "idle"); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); diff --git a/tests/github-signin-errors-ui.test.mjs b/tests/github-signin-errors-ui.test.mjs new file mode 100644 index 000000000..db7e6edd9 --- /dev/null +++ b/tests/github-signin-errors-ui.test.mjs @@ -0,0 +1,72 @@ +// The GitHub sign-in buttons show localized outcomes; main-process error text (IPC prefix, HTTP codes) only goes to the log. +import assert from "node:assert/strict"; +import test from "node:test"; +import { findAll, importWithFakeReact, tick } from "./helpers/fake-react.mjs"; + +const section = await importWithFakeReact("src/renderer/src/features/plugins/PluginSettingsSection.tsx", "PluginSettingsSection"); + +function harness(t, locale, githubAuth) { + const oldWindow = globalThis.window; + const warnings = []; + t.mock.method(console, "warn", (...args) => { warnings.push(args); }); + globalThis.window = { setTimeout, clearTimeout, canvasTTY: { githubAuth, appVersion: async () => "1.8.1" } }; + t.after(() => { section.__unmount(); globalThis.window = oldWindow; }); + section.__reset(); + const none = async () => ({}); + const props = { settings: { locale }, plugins: [], open: false, onPreviewPlugin: none, onInstallPlugin: none, onSearchPlugins: async () => [], + onShowcasePlugins: async () => [], onFetchPluginIcons: none, onPreviewManifests: none, onOpenBrowser: none, onCheckPluginUpdates: none, + onUpdatePlugin: none, onSetPluginModules: none, onSetPluginEnabled: none, onUninstallPlugin: none, onOpenPluginContribution: none }; + let tree; + const render = () => { section.__flush(); tree = section.__render(section.PluginSettingsSection, props); return tree; }; + const settle = async () => { for (let i = 0; i < 6; i++) { render(); await tick(); } render(); }; + const button = (label) => findAll(tree, (node) => node.type === "button" && node.props?.children === label)[0]; + return { render, settle, button, warnings, tree: () => tree }; +} + +const ipcFailure = new Error("Error invoking remote method 'github-auth:start': Error: GitHub device flow failed with HTTP 404."); + +test("a sign-in that cannot start shows the localized failure in the GitHub block and logs the details (ru)", async (t) => { + const status = { configured: true, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: "idle" }; + const ui = harness(t, "ru", { status: async () => status, start: async () => { throw ipcFailure; } }); + await ui.settle(); + const signIn = ui.button("Войти в системном браузере"); + assert.ok(signIn, "the sign-in button"); + signIn.props.onClick(); + await ui.settle(); + const json = JSON.stringify(ui.tree()); + assert.match(json, /Не удалось завершить вход в GitHub/u); + assert.doesNotMatch(json, /Error invoking|github-auth:start|HTTP 404/u); + assert.ok(ui.warnings.some(([, reason]) => reason === ipcFailure), "the details are logged"); +}); + +test("a build without an OAuth client shows its own line, not the raw error", async (t) => { + let configured = true; + const ui = harness(t, "en", { + status: async () => ({ configured, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: "idle" }), + start: async () => { configured = false; throw new Error("Error invoking remote method 'github-auth:start': Error: GitHub OAuth is not configured (missing client id)."); } + }); + await ui.settle(); + ui.button("Sign in with the system browser").props.onClick(); + await ui.settle(); + const json = JSON.stringify(ui.tree()); + assert.match(json, /this build has no OAuth Client ID/u); + assert.doesNotMatch(json, /Error invoking|missing client id/u); +}); + +test("a failed, denied or expired sign-in reads as an error; a cancelled one as a plain status line", async (t) => { + const line = (tree, text) => findAll(tree, (node) => node.type === "p" && node.props?.children === text)[0]; + for (const [state, text, error] of [ + ["failed", "Не удалось завершить вход в GitHub. Можно попробовать снова.", true], + ["denied", null, true], + ["expired", null, true], + ["cancelled", null, false] + ]) { + const ui = harness(t, "ru", { status: async () => ({ configured: true, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: state }) }); + await ui.settle(); + const notice = text ? line(ui.tree(), text) + : findAll(ui.tree(), (node) => node.type === "p" && /plugin-github-unavailable|plugin-settings__error/u.test(node.props?.className ?? ""))[0]; + assert.ok(notice, state); + assert.equal(notice.props.className, error ? "plugin-settings__error" : "plugin-github-unavailable", state); + assert.equal(notice.props.role, error ? "alert" : "status", state); + } +}); diff --git a/tests/home-layout.test.mjs b/tests/home-layout.test.mjs index ca37370a0..e960664c0 100644 --- a/tests/home-layout.test.mjs +++ b/tests/home-layout.test.mjs @@ -49,7 +49,7 @@ test("lists enabled and disabled plugin Home widgets beside core options", () => } }]; - assert.deepEqual(pluginHomeWidgetOptions(plugins), [{ + assert.deepEqual(pluginHomeWidgetOptions(plugins, "en"), [{ widgetId: "plugin:com.example.music:player", label: "Music", description: "Music plugin · Compact player", diff --git a/tests/plugin-surface-locale-scale.test.mjs b/tests/plugin-surface-locale-scale.test.mjs new file mode 100644 index 000000000..492771adc --- /dev/null +++ b/tests/plugin-surface-locale-scale.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { validatePluginManifest } from "../src/main/services/PluginManager.ts"; +import { contributionTitle, launchFieldLabel, pluginName } from "../src/shared/pluginLocalizedText.ts"; +import { pluginHomeWidgetOptions } from "../src/renderer/src/features/home/pluginHomeWidgets.ts"; +import { findAll, importWithFakeReact, tick } from "./helpers/fake-react.mjs"; + +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +// A plugin's texts the person reads (its name, its surfaces' titles, its launch fields' labels) may come in +// the app's languages, like the manifest's description.ru/description.en; the app shows its language's. +function manifest(overrides = {}) { + return { + apiVersion: 2, + id: "com.example.localized", + name: "Assistant", + "name.ru": "Ассистент", + "name.en": "Assistant EN", + version: "1.0.0", + description: "Localized surfaces.", + permissions: ["launch:contribute"], + settingsContribution: "settings", + contributions: [ + { id: "settings", kind: "canvas-app", title: "Settings", "title.ru": "Настройки", "title.en": "Settings EN", + entry: "apps/settings.html", defaultSize: { width: 680, height: 440 } }, + { id: "status", kind: "home-widget", title: "Status", "title.ru": "Статус", entry: "widgets/status.html", + defaultSize: { columns: 4, rows: 2 } }, + { id: "plain", kind: "window", title: "Plain", entry: "windows/plain.html", defaultSize: { width: 900, height: 620 } } + ], + services: [{ + id: "launcher", title: "Launcher", entry: "services/launcher.mjs", + launch: { + fields: [ + { key: "account", label: "Account", "label.ru": "Аккаунт", "label.en": "Account EN", kind: "select", + options: [{ value: "a", label: "A" }] }, + { key: "on", label: "On", kind: "boolean" } + ] + } + }], + ...overrides + }; +} + +test("the manifest validator keeps localized names, titles and labels and checks them like the texts they translate", () => { + const valid = validatePluginManifest(manifest()); + assert.equal(valid["name.ru"], "Ассистент"); + assert.equal(valid["name.en"], "Assistant EN"); + assert.deepEqual(valid.contributions.map((item) => [item.title, item["title.ru"], item["title.en"]]), [ + ["Settings", "Настройки", "Settings EN"], ["Status", "Статус", undefined], ["Plain", undefined, undefined] + ]); + assert.ok(!("title.ru" in valid.contributions[2]), "absent translations stay absent"); + assert.deepEqual(valid.services[0].launch.fields[0], { + key: "account", label: "Account", "label.ru": "Аккаунт", "label.en": "Account EN", kind: "select", options: [{ value: "a", label: "A" }] + }); + assert.deepEqual(valid.services[0].launch.fields[1], { key: "on", label: "On", kind: "boolean" }); + + const long = "ж".repeat(81); + assert.throws(() => validatePluginManifest(manifest({ "name.ru": long })), /name\.ru/u, "80 characters, like name"); + assert.throws(() => validatePluginManifest(manifest({ "name.en": "" })), /name\.en/u); + assert.throws(() => validatePluginManifest(manifest({ "name.de": "Assistent" })), /name\.de/u, "only the app's languages"); + const contribution = (extra) => manifest({ contributions: [{ ...manifest().contributions[0], ...extra }] }); + assert.throws(() => validatePluginManifest(contribution({ "title.ru": long })), /title\.ru/u); + assert.throws(() => validatePluginManifest(contribution({ "title.en": 7 })), /title\.en/u); + assert.throws(() => validatePluginManifest(contribution({ "title.de": "Einstellungen" })), /title\.de/u); + const field = (extra) => manifest({ services: [{ ...manifest().services[0], launch: { fields: [{ key: "on", label: "On", kind: "boolean", ...extra }] } }] }); + assert.throws(() => validatePluginManifest(field({ "label.ru": long })), /label\.ru/u); + assert.throws(() => validatePluginManifest(field({ "label.en": "" })), /label\.en/u); + assert.throws(() => validatePluginManifest(field({ "label.de": "An" })), /label\.de/u); +}); + +test("the helpers pick the UI language's text and fall back to the plain one", () => { + const valid = validatePluginManifest(manifest()); + const [settings, status] = valid.contributions; + assert.equal(pluginName(valid, "ru"), "Ассистент"); + assert.equal(pluginName(valid, "en"), "Assistant EN"); + assert.equal(pluginName({ name: "Plain" }, "ru"), "Plain"); + assert.equal(contributionTitle(settings, "ru"), "Настройки"); + assert.equal(contributionTitle(status, "en"), "Status", "no title.en: title"); + assert.equal(launchFieldLabel(valid.services[0].launch.fields[0], "ru"), "Аккаунт"); + assert.equal(launchFieldLabel(valid.services[0].launch.fields[1], "ru"), "On"); +}); + +test("the schema and the plugin API types document the localized keys and the UI scale", async () => { + const schema = JSON.parse(await read("docs/canvastty-plugin.schema.json")); + for (const key of ["name.ru", "name.en"]) assert.equal(schema.properties[key].maxLength, 80, key); + for (const key of ["title.ru", "title.en"]) assert.equal(schema.$defs.baseContribution.properties[key].maxLength, 80, key); + for (const key of ["label.ru", "label.en"]) assert.equal(schema.$defs.launchField.properties[key].maxLength, 80, key); + const api = await read("docs/plugin-api.d.ts"); + assert.match(api, /appearance: \{[^}]*uiScale: number;/su); + assert.match(api, /"label\.ru"\?: string;/u); + for (const doc of ["docs/plugins.md", "docs/plugins.ru.md", "docs/plugins.zh-CN.md"]) { + const text = await read(doc); + assert.match(text, /appearance\.uiScale/u, doc); + for (const key of ["name.ru", "title.ru", "label.ru"]) assert.ok(text.includes(`"${key}"`), `${doc}: ${key}`); + } +}); + +test("HOME composition lists plugin widgets by their localized title and plugin name", () => { + const plugin = { manifest: validatePluginManifest(manifest()), enabled: true }; + const [widget] = pluginHomeWidgetOptions([plugin], "ru"); + assert.equal(widget.label, "Статус"); + assert.equal(widget.description, "Ассистент"); + assert.equal(pluginHomeWidgetOptions([plugin], "en")[0].label, "Status"); +}); + +test("the launcher shows the plugin name and its field labels in the UI language", async (t) => { + const { LaunchOptionsSection, __render, __flush, __reset } = await importWithFakeReact( + "src/renderer/src/features/launcher/LaunchOptionsSection.tsx", "LaunchOptionsSection"); + const plugin = { + manifest: validatePluginManifest(manifest()), sourceUrl: "", enabled: true, installedAt: 0, selectedModules: [], + enabledHooks: [], nativeCodeTrusted: true, decisionsMayAllow: false + }; + globalThis.window = { canvasTTY: { plugins: { list: async () => [plugin], launchFieldOptions: async () => ({}) } } }; + t.after(() => { delete globalThis.window; }); + __reset(); + const props = { provider: "claude", locale: "ru", onChange() {}, onEnvironmentChange() {} }; + let tree = __render(LaunchOptionsSection, props); + const settle = () => { for (let round = 0; round < 5; round++) { __flush(); tree = __render(LaunchOptionsSection, props); } }; + await tick(); + settle(); + const text = (node) => typeof node === "string" ? node : Array.isArray(node) ? node.map(text).join("") + : node && typeof node === "object" ? text(node.props?.children) : node == null || node === false ? "" : String(node); + const fieldset = findAll(tree, (node) => node.type === "fieldset" && node.key === plugin.manifest.id)[0]; + assert.match(text(fieldset), /Ассистент/u, "Use in the UI language"); + findAll(fieldset, (node) => node.type === "input" && node.props.type === "checkbox")[0].props.onChange({ target: { checked: true } }); + settle(); + // The field inputs are a plain function component without hooks: render it as React would. + const inputs = findAll(tree, (node) => typeof node.type === "function" && Array.isArray(node.props?.fields))[0]; + assert.equal(inputs.props.locale, "ru"); + const labels = findAll(inputs.type(inputs.props), (node) => node.type === "label") + .map((label) => text(findAll(label, (node) => node.type === "span")[0])); + assert.deepEqual(labels, ["Аккаунт", "On"]); +}); + +// The plugin page receives the root UI scale with the locale and palette, and again whenever it changes. +test("plugin pages and canvas apps receive the root UI scale live, and the card shows localized titles", async (t) => { + const { PluginCanvasCard, __render, __unmount, __reset } = await importWithFakeReact( + "src/renderer/src/features/plugins/PluginCanvasCard.tsx", "PluginCanvasCard"); + globalThis.window = { + innerWidth: 1600, innerHeight: 1000, setTimeout: () => 1, clearTimeout() {}, addEventListener() {}, removeEventListener() {}, + canvasTTY: { plugins: { onStorageChanged: () => () => undefined, onServiceEvent: () => () => undefined } } + }; + globalThis.document = { visibilityState: "visible", addEventListener() {}, removeEventListener() {} }; + t.after(() => { __unmount(); delete globalThis.window; delete globalThis.document; }); + + const valid = validatePluginManifest(manifest({ services: undefined, permissions: [] })); + const contribution = valid.contributions[0]; + const plugin = { manifest: valid, sourceUrl: "https://github.com/example/assistant", selectedModules: [], enabled: true, installedAt: 0 }; + const camera = { get: () => ({ x: 0, y: 0, zoom: 1 }), set() {}, subscribe: () => () => undefined }; + const cardProps = { + instance: { id: "p1", pluginId: valid.id, contributionId: contribution.id, title: contribution.title, position: { x: 0, y: 0 }, size: { width: 680, height: 440 } }, + plugin, contribution, locale: "ru", palette: "sage", uiScale: 1.25, camera, stackIndex: 1, snapEnabled: false, + sessions: [], limits: null, getSnapTargets: () => [], captureCanvasWheelOverWidgets: false, + onActivate() {}, onBoundsChange() {}, onDispose() {}, onOpenLauncher() {}, onError() {}, + onWidgetFocus() {}, onWidgetHoverChange() {}, onCanvasWheel() {} + }; + __reset(); + const card = __render(PluginCanvasCard, cardProps); + const frameElement = findAll(card, (node) => typeof node.type === "function" && node.props && "canvasInstanceId" in node.props)[0]; + const PluginFrame = frameElement?.type; + assert.ok(frameElement, "the card renders a PluginFrame"); + assert.equal(frameElement.props.uiScale, 1.25, "the card passes the root UI scale on"); + const header = findAll(card, (node) => node.type === "header")[0]; + assert.deepEqual(findAll(header, (node) => node.type === "strong" || node.type === "small").map((node) => node.props.children), + ["Настройки", "Ассистент"]); + __unmount(); + + // The frame itself: what the page is told on load and when the person changes the scale. + __reset(); + const posted = []; + const frameProps = { ...frameElement.props }; + const iframe = __render(PluginFrame, frameProps); + assert.equal(iframe.props.title, "Ассистент: Настройки"); + iframe.props.ref.current = { contentWindow: { postMessage: (message) => posted.push(message) } }; + iframe.props.onLoad(); + const contexts = () => posted.filter((message) => message.type === "context").map((message) => message.value.appearance); + assert.deepEqual(contexts().at(-1), { locale: "ru", palette: "sage", uiScale: 1.25 }); + + const before = posted.length; + __render(PluginFrame, { ...frameProps, uiScale: 1.1 }); + assert.ok(posted.length > before, "a changed scale is sent at once"); + assert.deepEqual(contexts().at(-1), { locale: "ru", palette: "sage", uiScale: 1.1 }); +}); + +test("HOME widgets and separate plugin windows get the same UI scale", async () => { + const home = await read("src/renderer/src/features/home/HomeZone.tsx"); + assert.match(home, /]*uiScale=\{settings\.uiScale\}/su); + const canvas = await read("src/renderer/src/features/workspace/WorkspaceCanvas.tsx"); + assert.match(canvas, /]*uiScale=\{settings\.uiScale\}/su); + const ipc = await read("src/main/ipc/registerIpc.ts"); + assert.match(ipc, /appearance: \{ locale: settings\.get\(\)\.locale, palette: settings\.get\(\)\.palette, uiScale: settings\.get\(\)\.uiScale \}/u); +}); + +// Settings → Agents, Extension native code: the "May allow agent actions" help line is help text, not the +// monospace metadata line (`.agent-hooks__copy small` is the mono plugin-name style). +test("the decisions 'may allow' help line uses the normal help text style", async () => { + const source = await read("src/renderer/src/features/settings/PluginServicesSettings.tsx"); + assert.match(source, /\{t\(locale, "pluginDecisionsMayAllowDescription"\)\}<\/span>/u); + assert.doesNotMatch(source, /\{t\(locale, "pluginDecisionsMayAllowDescription"\)\}/u); + const css = await read("src/renderer/src/styles/app.css"); + assert.match(css, /\.agent-hooks__copy small \{ font: [^}]*var\(--font-mono\)/u, "small is the monospace line"); + assert.doesNotMatch(css, /\.agent-hooks__copy > span \{[^}]*--font-mono/u, "the span is the normal help text"); +}); diff --git a/tests/ru-shortcut-hints-i18n.test.mjs b/tests/ru-shortcut-hints-i18n.test.mjs new file mode 100644 index 000000000..c5d0b2575 --- /dev/null +++ b/tests/ru-shortcut-hints-i18n.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { t } from "../src/renderer/src/lib/i18n.ts"; + +const canvasPath = new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url); +const referencePath = new URL("../src/renderer/src/components/ShortcutReference.tsx", import.meta.url); +const settingsPath = new URL("../src/renderer/src/features/settings/SettingsPanel.tsx", import.meta.url); + +// Latin words that may stay untranslated in Russian hints: keyboard and mouse key +// names, plus product names and the HOME zone label that the UI shows verbatim. +const allowedLatin = new Set([ + "Shift", "Command", "Super", "Ctrl", "Control", "Alt", "Option", "Meta", + "Enter", "Escape", "Esc", "Space", "Tab", "Backspace", "Delete", + "PageUp", "PageDown", "Mouse3", "Mouse4", "Mouse5", + "HOME", "Codex", "CLI", "TUI" +]); + +function latinWords(text) { + return [...text.matchAll(/[A-Za-z][A-Za-z0-9]*/g)].map((match) => match[0]) + .filter((word) => !allowedLatin.has(word) && !/^F\d{1,2}$/.test(word)); +} + +async function hintKeys() { + const [canvas, reference] = await Promise.all([readFile(canvasPath, "utf8"), readFile(referencePath, "utf8")]); + const aside = canvas.slice(canvas.indexOf('