chuijs 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,117 @@
1
+ # Changelog
2
+
3
+ Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
4
+ версии — [Semantic Versioning](https://semver.org/lang/ru/).
5
+
6
+ ## [4.0.2] - 2026-09-29
7
+
8
+ ### Исправлено
9
+
10
+ - **Заголовок страницы вернулся в шапку.** `Route#go()` больше не подмешивает название в
11
+ содержимое: узел `page_title` (появился в 4.0.1) и его стили удалены. Он дублировал заголовок,
12
+ который страница рисует сама, а `Page#render()` отдаёт один и тот же узел — повторный переход
13
+ на ту же страницу (кнопка «Назад» из `Page#setBackButton()`) добавлял второй заголовок.
14
+ Название показывает узел `page_name` в шапке, у страницы с меню-баром он по-прежнему скрыт —
15
+ место занимает полоса меню.
16
+
17
+ ### Изменено
18
+
19
+ - **`Desktop.app.path()` отвечает значением, а не промисом.** Из путей страница собирает свои
20
+ (файлы, логи, кеш) на старте, где `await` пришлось бы протаскивать через весь код инициализации.
21
+ Путь приходит по синхронному каналу (`SERVICES_SYNC_CALLS`: `ipcRenderer.sendSync` в странице,
22
+ `ipcMain.on` + `event.returnValue` в main) и кешируется — за одним именем в main ходят один раз.
23
+ Ответ идёт конвертом `{value}`/`{error}`: у `sendSync` нет канала для исключения, поэтому отказ
24
+ (имя вне белого списка) приходит странице обычной ошибкой с текстом main. Прежний
25
+ `await Desktop.app.path(...)` продолжает работать — `await` на строке возвращает ту же строку.
26
+ Асинхронный канал `APP_PATH` оставлен: его ключ есть в карте `window.chui.services`.
27
+
28
+ ### Добавлено
29
+
30
+ - **`Desktop.app.quit()` и `Desktop.app.restart()`** — закрытие и перезапуск приложения со страницы,
31
+ в том числе из безопасного окна (`window.chui.desktop.app.quit()`). Идут через `Main.stop()`/
32
+ `Main.restart()`, то есть теми же шагами, что пункт меню «Выход»: снимаются обработчики сервисов,
33
+ глобальные клавиши и подписки, а не просто закрывается окно. Отвечают значением, а не промисом
34
+ (синхронные каналы `appQuitSync`/`appRestartSync`), поэтому `await` не обязателен; выход
35
+ откладывается `setImmediate`, чтобы ответ успел уйти странице. Асинхронные каналы
36
+ `APP_QUIT`/`APP_RESTART` оставлены: их ключи есть в карте `window.chui.services`.
37
+ - Тесты `test/chui_services_sync.test.js` и `test/chui_app_lifecycle.test.js`: сверяют синхронные
38
+ каналы (main ↔ мост preload ↔ страница) и то, что закрытие с перезапуском идут именно через
39
+ `Main.stop()`/`Main.restart()`.
40
+
41
+ ### Удалено
42
+
43
+ - **Класс `App` и вспомогательный `Application` удалены.** `App.get()`, `App.getSession()`,
44
+ `App.getWebContents()` и все `App.*Path()` больше не экспортируются. Пути теперь отдаёт
45
+ `Desktop.app.path(name)` — и на странице, и в main-процессе: там канала нет, поэтому путь
46
+ берётся у Electron напрямую с той же проверкой белого списка `APP_PATH_NAMES`. Сырые объекты
47
+ Electron в main отдают `Desktop.app.get()`, `Desktop.app.getSession()` и
48
+ `Desktop.app.getWebContents()`; со страницы они бросают ошибку с подсказкой (`Desktop.session.*`,
49
+ `Desktop.dialog` или свой канал из `security.channels`).
50
+ **Ломающее:** `const { App } = require('chuijs')` перестаёт работать.
51
+ - Тест `test/chui_app_paths.test.js` переписан: сверяет белый список с ожидаемым набором имён
52
+ путей и проверяет оба валидатора — `appPathByName` (`index.js`) и `#appPathInMain` (`desktop.js`).
53
+
54
+ ### Пакет
55
+
56
+ - Версия 4.0.2.
57
+
58
+ ## [4.0.1] - 2026-09-28
59
+
60
+ ### Добавлено
61
+
62
+ - **`StickyBar`** — прилипающая полоса: компонент остаётся у верха области прокрутки, пока его
63
+ содержимое не кончилось. В покое не рисует фона и рамки, у закреплённой появляются материал,
64
+ волосяной край и тень; состояние отдаётся в `data-stuck` и `onChange(stuck)`.
65
+ - Раздел `StickyBar` в витрине: карточка в каталоге группы «Компоновка и навигация» с показом
66
+ закрепления.
67
+ - Страница **«История изменений»** на сайте документации: собирается из `CHANGELOG.md`, чтобы
68
+ журнал не расходился с копией в доке. Дорога к ней — из навигации и с бейджа версии на главной.
69
+ - Тест `test/chui_app_paths.test.js`: белый список путей приложения не разойдётся с `App.*Path()`.
70
+
71
+ ### Изменено
72
+
73
+ - **Рендер markdown переведён с `showdown` на `markdown-it`** (HTML), обратный разбор — на
74
+ `turndown`. `showdown` не обновлялся с 2022 года, исправленной версии у него нет, а аудит
75
+ показывал три неустранённые уязвимости (ReDoS и две XSS) именно в пути «markdown → innerHTML».
76
+ Новый разбор идёт с `html: false`: сырой HTML в `markdownText` теперь **показывается текстом,
77
+ а не исполняется** — это и закрывает XSS. Таблицы и `~~зачёркивание~~` работают как раньше,
78
+ авто-ссылок нет (адрес становится ссылкой только явным `[текст](адрес)`). Сайт документации
79
+ собирается тем же `markdown-it`; `npm audit` — 0 уязвимостей.
80
+ - **`@electron/remote` убран полностью** — Electron не рекомендует отдавать странице модули
81
+ main-процесса. Кнопки окна ходят в main по каналам `WINDOW_ACTION` / `WINDOW_STATE` (обработчики
82
+ регистрируются и в обычном режиме, `enableServices`), а пути, диалоги и сессия доступны как
83
+ `Desktop.app.path()/info()`, `Desktop.dialog.*`, `Desktop.session.*` или свой канал из
84
+ `security.channels`; в безопасном окне то же отдаёт мост `window.chui`. `chooseFile` со страницы
85
+ идёт по IPC в своё окно, в main работает напрямую. **Ломающее:** `App.get()`, `App.getSession()`,
86
+ `App.getWebContents()` и `App.*Path()` теперь только для main-процесса — со страницы они бросают
87
+ ошибку с подсказкой. Убраны устаревшие `enableRemoteModule` из `webPreferences`.
88
+ - **`Spinner`** переделан: вместо трёх вложенных колец — одно тонкое кольцо с приглушённой
89
+ дорожкой и гладкой дугой со скруглёнными концами. Цвет дуги задаётся `style.color`,
90
+ при `prefers-reduced-motion` вращение замедляется. Публичный API сохранён.
91
+ - **Кнопки окна** (`AppLayout`): значки свернуть/развернуть/восстановить/закрыть нарисованы
92
+ линиями (SVG) вместо лигатур шрифта иконок — не зависят от Material Symbols.
93
+ - **Шапка**: низ скруглён (логические радиусы), волосяная черта снизу сохранена.
94
+ - **`UpdateNotification`**: под спиннером больше нет заливки плашки (`notification_icon_plain`) —
95
+ подложка читалась вторым фоном вокруг тонкого индикатора.
96
+ - Витрина: липкая навигация по разделам переведена на `StickyBar`.
97
+ - `DataGrid.exportToClipboard()`: резервный буфер обмена берётся через `Desktop`, а не напрямую
98
+ из `electron` — работает и в безопасном окне.
99
+ - `chui_functions.setStyles()` кэширует прочитанные таблицы стилей: один файл отдаётся разным
100
+ `id` без повторного чтения с диска.
101
+ - Документация: сайт пересобран, добавлены страницы `StickyBar` и «История изменений», в README —
102
+ раздел про разметку `markdownText`, её безопасный режим и группы моста `window.chui.desktop`.
103
+ Примеры в разделах о безопасности больше не упоминают `enableRemoteModule` — опции, которой в
104
+ `webPreferences` больше нет.
105
+
106
+ ### Пакет
107
+
108
+ - Версия 4.0.1.
109
+ - Удалена неиспользуемая зависимость `dataurl`: в коде не было ни одного `require`, ни реэкспорта.
110
+ - Добавлены `repository`, `bugs`, `engines` и карта экспортов (`exports`) с сохранением глубоких
111
+ импортов вида `require('chuijs/framework/modules/chui_fonts')`.
112
+ - Явно указан `"type": "commonjs"`.
113
+
114
+ ## [4.0.0]
115
+
116
+ - Версия 4.0.0: реструктуризация модулей, локализация и RTL, десктоп-сервисы и безопасный режим
117
+ окон, статический сайт документации (`npm run docs:build`).
package/README.md CHANGED
@@ -60,7 +60,7 @@ render(() => new App()).catch(err => Log.error(err))
60
60
  #### exampleApp / main.js
61
61
  ```javascript
62
62
  /** main.js */
63
- const { Main, MenuItem, path, App } = require('chuijs');
63
+ const { Main, MenuItem, path, Desktop } = require('chuijs');
64
64
  const main = new Main({
65
65
  name: "exampleApp",
66
66
  // размеры окна задаются вложенным объектом sizes
@@ -73,9 +73,9 @@ const main = new Main({
73
73
  render: `${__dirname}/app/app.js`,
74
74
  devTools: false,
75
75
  resizable: true,
76
- // папка загрузок (по умолчанию App.downloadsPath())
76
+ // папка загрузок: если не задана, путь выбирает Electron
77
77
  paths: {
78
- downloadPath: path.join(App.userDataPath(), "downloads")
78
+ downloadPath: path.join(Desktop.app.path("userData"), "downloads")
79
79
  }
80
80
  /** icon: `${__dirname}/resources/icons/app/icon.png` */
81
81
  });
@@ -95,8 +95,9 @@ main.start({
95
95
  > поэтому окно доступно через `main.getWindow()` только после старта.
96
96
  #### Download Session в main.js
97
97
  ```javascript
98
- const { App } = require('chuijs');
99
- App.get().on('session-created', (session) => {
98
+ const { Desktop } = require('chuijs');
99
+ const app = Desktop.app.get(); // сырой `app` — только в main-процессе
100
+ app.on('session-created', (session) => {
100
101
  session.on('will-download', (e, item, contents) => {
101
102
  // ...
102
103
  });
@@ -124,14 +125,14 @@ async function run() {
124
125
  Создаёт окно, трей и управляет жизненным циклом приложения.
125
126
 
126
127
  ```javascript
127
- const { Main, MenuItem, App } = require('chuijs');
128
+ const { Main, MenuItem, Desktop } = require('chuijs');
128
129
  const main = new Main({
129
130
  name: 'exampleApp',
130
131
  sizes: { width: 1366, height: 768, minWidth: 960, minHeight: 540 },
131
132
  render: `${__dirname}/app/app.js`,
132
133
  devTools: false,
133
134
  resizable: true,
134
- paths: { downloadPath: path.join(App.userDataPath(), 'downloads') }
135
+ paths: { downloadPath: path.join(Desktop.app.path('userData'), 'downloads') }
135
136
  });
136
137
  main.start({ hideOnClose: false, tray: [new MenuItem().quit('Выход')] });
137
138
  ```
@@ -178,29 +179,37 @@ main.getWindows(); // [{name, window}, …]
178
179
  `stop()` и `restart()` снимают свои обработчики: IPC (`show_system_notification`, `SEND_LOG_TEXT`,
179
180
  `updateInstallConfirm`), обработчики десктоп-сервисов вместе с глобальными клавишами,
180
181
  подписку апдейтера и глобальные обработчики ошибок процессов —
181
- повторный `start()` подпишет их заново по одному разу.
182
+ повторный `start()` подпишет их заново по одному разу. Со страницы то же делают
183
+ `Desktop.app.quit()` и `Desktop.app.restart()`: страница — код самого приложения, поэтому своей
184
+ кнопке «Выход» не нужен отдельный канал в каждом проекте. Оба отвечают значением, а не промисом
185
+ (синхронный канал, как у `Desktop.app.path`), поэтому `await` не обязателен.
182
186
 
183
- #### `App`
184
- Обёртка над `app` Electron.
187
+ Пути приложения отдаёт `Desktop.app.path(name)` — и в main-процессе, и на странице, значением
188
+ без промиса (на странице — синхронным каналом, в main — прямо у Electron):
185
189
 
186
190
  ```javascript
187
- const { App } = require('chuijs');
188
- App.get(); // app
189
- App.getSession(); // session
190
- App.getWebContents(); // webContents
191
+ const { Desktop } = require('chuijs');
192
+ Desktop.app.path('userData'); // .../AppData/<name>
193
+ Desktop.app.path('downloads');
194
+ Desktop.app.path('logs');
191
195
  ```
192
196
 
193
- #### `App.*Path()`
194
- Статические системные пути (все возвращают строку).
197
+ Имена берутся из белого списка: `home`, `appData`, `userData`, `sessionData`, `temp`, `exe`,
198
+ `module`, `desktop`, `documents`, `downloads`, `music`, `pictures`, `videos`, `logs`, `recent`,
199
+ `crashDumps`; всё, что вне списка, отклоняется. Со страницы то же значение доступно как
200
+ `window.chui.desktop.app.path('userData')`.
201
+
202
+ Сырые объекты Electron (`app`, `session`, `webContents`) странице не отдают — Electron не
203
+ рекомендует `remote`. В main-процессе их отдают `Desktop.app.get()`, `Desktop.app.getSession()`
204
+ и `Desktop.app.getWebContents()`:
195
205
 
196
206
  ```javascript
197
- const { App } = require('chuijs');
198
- App.userDataPath(); // .../AppData/<name>
199
- App.downloadsPath();
200
- App.logsPath();
207
+ const app = Desktop.app.get(); // main.js
208
+ app.on('session-created', (session) => { /* … */ });
201
209
  ```
202
210
 
203
- Полный список: `homePath()`, `appDataPath()`, `userDataPath()`, `sessionDataPath()`, `logsPath()`, `tempPath()`, `exePath()`, `modulePath()`, `desktopPath()`, `documentsPath()`, `downloadsPath()`, `musicPath()`, `picturesPath()`, `videosPath()`, `recentPath()`, `crashDumpsPath()`.
211
+ Со страницы эти методы бросают ошибку с подсказкой: вместо них берут `Desktop.session.*`,
212
+ `Desktop.dialog` или свой канал из `security.channels`.
204
213
 
205
214
  #### `Log`
206
215
  Пишет в `userData/logs/app_<date>.log`.
@@ -277,7 +286,8 @@ await bot.sendMessage({ chat_id: '123', text: 'привет' });
277
286
  доступны только там), а страница работает с ними через `Desktop`: он сам выбирает транспорт —
278
287
  канал `ipcRenderer.invoke` в обычном режиме или методы `window.chui.services` (preload +
279
288
  `contextBridge`) в безопасном. Имена каналов лежат в одном модуле `chui_services/chui_channels`,
280
- поэтому main и рендерер не расходятся.
289
+ поэтому main и рендерер не расходятся. Пути приложения — единственное исключение: они идут
290
+ синхронным каналом (`Desktop.app.path` отвечает значением, а не промисом, см. ниже).
281
291
 
282
292
  ```javascript
283
293
  const { Desktop } = require('chuijs');
@@ -299,6 +309,16 @@ offTheme();
299
309
  await Desktop.print.page(); // системный диалог печати текущего окна
300
310
  await Desktop.print.html('<h1>Отчёт</h1>'); // печать HTML из скрытого окна
301
311
  await Desktop.print.pdf(html, {path: '/tmp/отчёт.pdf'}); // PDF без диалога (или с ним, без path)
312
+ const userData = Desktop.app.path('userData'); // каталог настроек приложения, сразу
313
+ await Desktop.app.info(); // {name, version, locale, isPackaged}
314
+ const app = Desktop.app.get(); // сырой `app` — только в main-процессе
315
+ Desktop.app.quit(); // выход: тот же, что у пункта меню «Выход»
316
+ Desktop.app.restart(); // перезапуск процесса (состояние не переносится)
317
+ const {canceled, filePaths} = await Desktop.dialog.open({properties: ['openFile']});
318
+ await Desktop.dialog.save({defaultPath: '/tmp/отчёт.pdf'});
319
+ await Desktop.dialog.message({type: 'question', message: 'Продолжить?'});
320
+ await Desktop.session.clearCache(); // кеш своей сессии
321
+ await Desktop.session.clearStorageData({storages: ['cookies']});
302
322
  ```
303
323
 
304
324
  Глобальная клавиша регистрируется в main один раз на комбинацию: сколько бы окон ни подписалось,
@@ -389,9 +409,9 @@ const preview = await main.createWindow({
389
409
 
390
410
  Что меняется в таком окне:
391
411
 
392
- - `nodeIntegration: false`, `contextIsolation: true`, `enableRemoteModule: false`,
393
- `nodeIntegrationInSubFrames: false`, `webviewTag: false` — `require`, `process` и `module`
394
- в странице недоступны, `@electron/remote` тоже;
412
+ - `nodeIntegration: false`, `contextIsolation: true`, `nodeIntegrationInSubFrames: false`,
413
+ `webviewTag: false` — `require`, `process` и `module` в странице недоступны, модули
414
+ main-процесса (в том числе `@electron/remote`) тоже;
395
415
  - preload'ом становится мост фреймворка (`framework/modules/chui_bridge/preload.js`),
396
416
  а `render` — **скрипт документа**: мост подключает его как обычный `<script>` после
397
417
  `DOMContentLoaded`. Поэтому код вроде `require('chuijs')` в таком окне не работает —
@@ -409,6 +429,13 @@ window.chui.desktop.clipboard.readText(); // то же, что Desktop из re
409
429
  window.chui.desktop.recent.list();
410
430
  window.chui.desktop.progress.set(0.4);
411
431
  const off = window.chui.desktop.shortcuts.on('Mod+Shift+P', () => {});
432
+ window.chui.desktop.app.path('userData'); // путь приложения — сразу, без промиса
433
+ await window.chui.desktop.app.info(); // {name, version, locale, isPackaged}
434
+ window.chui.desktop.app.quit(); // закрыть всё приложение (окно — window.chui.window)
435
+ window.chui.desktop.app.restart(); // перезапустить приложение (тоже без промиса)
436
+ const res = await window.chui.desktop.dialog.open({properties: ['openFile']});
437
+ await window.chui.desktop.dialog.message({type: 'question', message: 'Продолжить?'});
438
+ await window.chui.desktop.session.clearCache();
412
439
  window.chui.theme.get(); // 'light' | 'dark'
413
440
  window.chui.theme.onChange((theme) => {});
414
441
  window.chui.window.minimize(); window.chui.window.toggleMaximize(); window.chui.window.close();
@@ -435,7 +462,9 @@ const { BrowserWindow, ipcMain, ipcRenderer, shell, path, fs, os, request,
435
462
  transliterate, systemPreferences, chooseFile, store, downloadSession } = require('chuijs');
436
463
  ```
437
464
 
438
- `chooseFile` — это `dialog` из `@electron/remote/main`, `store` — готовый экземпляр `new Store()`.
465
+ `chooseFile` — диалоги `showOpenDialog` / `showSaveDialog` / `showMessageBox`: со страницы они идут
466
+ по IPC в окно, которое их вызвало (`Desktop.dialog`), а в main — напрямую `dialog` Electron.
467
+ `store` — готовый экземпляр `new Store()`.
439
468
 
440
469
  #### Автоматическое обновление приложения
441
470
  Только AppImage и NSIS.
@@ -604,6 +633,12 @@ Desktop.locale.onChange((locale) => I18n.setLocale(locale));
604
633
  DOM-элемент, поэтому внутри `Page.add(...)`, `Card.add(...)`, `ContentBlock.add(...)` передаётся
605
634
  сам объект, а в `Form.components` / `SlideShow.SLIDE` / `FieldSet.components` — тоже объекты.
606
635
 
636
+ **Разметка `markdownText`.** Компоненты с этой опцией (`Label`, `Tooltip`, `Badge`, `Alert`,
637
+ `Notification`, `ToastQueue`, `Chat`, `Tour`, `MarkdownEditor`) рендерят её `markdown-it` в безопасном
638
+ режиме: сырой HTML из разметки не исполняется, а показывается текстом — так закрыт XSS через
639
+ `markdownText`. Таблицы и `~~зачёркивание~~` поддерживаются, авто-ссылок нет: адрес становится
640
+ ссылкой только явным `[текст](адрес)`. Обратный разбор (`htmlToMarkdown`) делает `turndown`.
641
+
607
642
  Описание каждого компонента — отдельная страница статического сайта документации в [`docs/`](docs/):
608
643
  [`docs/index.html`](docs/index.html) — полный индекс по группам и алфавиту, а у каждой страницы сверху
609
644
  ссылка на группу и общий список. Сайт собирается генератором `node docs/build.js`
@@ -621,7 +656,7 @@ DOM-элемент, поэтому внутри `Page.add(...)`, `Card.add(...)`
621
656
  [Константы и статические фабрики](docs/topics/constants.html), [Сценарии целиком](docs/topics/scenarios.html),
622
657
  [Витрина компонентов](docs/topics/showcase.html).
623
658
 
624
- Модули и десктоп-сервисы (`Main`, `App`, `I18n`, `Theme`, `Desktop`, `Log`) описаны выше, в разделе «Модули».
659
+ Модули и десктоп-сервисы (`Main`, `I18n`, `Theme`, `Desktop`, `Log`) описаны выше, в разделе «Модули».
625
660
 
626
661
  ### В разработке
627
662
 
@@ -13,6 +13,7 @@ const {DownloadNotification} = require("../../components/chui_notification/notif
13
13
  const {Log} = require("../../../framework/modules/chui_logger/chui_logger");
14
14
  const {MenuKeyboard, makeKeyboardButton} = require('../../modules/chui_a11y/chui_a11y');
15
15
  const {LAYOUT_ROLE, LayoutRefs, registerLayout, requireLayoutNode} = require('../../modules/chui_layout/chui_layout');
16
+ const {BRIDGE_CHANNELS} = require('../../modules/chui_services/chui_channels');
16
17
  const {OverlayScrollbars} = require('../../modules/chui_scrollbar/chui_scrollbar');
17
18
  const {MenuBar} = require('../../components/chui_menu_bar/menu_bar');
18
19
  const {Avatar} = require('../../components/chui_avatar/avatar');
@@ -88,19 +89,12 @@ class Route extends Events {
88
89
  //
89
90
  setPageName(page.getTitle());
90
91
  center.innerHTML = '';
91
- const page_node = page.render();
92
- // Заголовок страницы — в содержимом, первым блоком (вид — `page_title` в
93
- // chui_page/styles.css). Текст идёт текстовым узлом, а не разметкой: имя
94
- // страницы приходит от приложения и могло бы принести теги
95
- const title = page.getTitle();
96
- if (title !== undefined && title !== null && String(title) !== "") {
97
- const block = document.createElement("page_title");
98
- const icon = page.getIcon();
99
- if (icon !== undefined && icon !== null) block.innerHTML = String(icon);
100
- block.appendChild(document.createTextNode(String(title)));
101
- page_node.prepend(block);
102
- }
103
- center.appendChild(page_node);
92
+ // Содержимое собирает сама страница (`Page#render`), оболочка в него не подмешивает
93
+ // ничего: узел `page_title` (был здесь) дублировал заголовок, который страница
94
+ // рисует сама, а `Page#render` отдаёт один и тот же узел — повторный переход на тот
95
+ // же экземпляр страницы (кнопка «Назад» из `Page#setBackButton`) добавлял второй
96
+ // заголовок. Место названия — шапка (`setPageName`).
97
+ center.appendChild(page.render());
104
98
  const _page = center.querySelector('page');
105
99
  new Animation(_page).fadeIn();
106
100
  // Стирания `center.removeAttribute("style")` здесь больше нет: инлайновых
@@ -157,6 +151,22 @@ function makeHeaderButtonBox(options = { title: undefined, icon: undefined, reve
157
151
  return button;
158
152
  }
159
153
 
154
+ /**
155
+ * Значки кнопок окна нарисованы линиями, а не взяты из шрифта иконок: системные кнопки
156
+ * должны выглядеть одинаково, даже если Material Symbols недоступен (веб-сборка, безопасное
157
+ * окно, где шрифт фреймворка не подключён). «Свернуть»/«развернуть» — зеркальные шевроны,
158
+ * «восстановить» — контурный ромб, «закрыть» — крест. Все четыре значка вписаны в один
159
+ * квадрат 20×20 и центрированы геометрически (чернила симметричны относительно 10,10),
160
+ * поэтому в кнопке они стоят ровно и читаются одной тройкой. Толщина 1.3 и скругления
161
+ * подобраны под вид набора; цвет наследуется от кнопки через `currentColor`.
162
+ */
163
+ const WINDOW_ICONS = {
164
+ minimize: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.5 7.5 L10 12.5 L15.5 7.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
165
+ maximize: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4.5 12.5 L10 7.5 L15.5 12.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
166
+ restore: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10 5 L15 10 L10 15 L5 10 Z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
167
+ close: '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M5 5 L15 15 M15 5 L5 15" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>',
168
+ };
169
+
160
170
  class WindowControls {
161
171
  // Управление кнопками
162
172
  #box = document.createElement("wc_box")
@@ -166,70 +176,73 @@ class WindowControls {
166
176
  // Подписка на нересайзящееся окно: set() вызывается и из конструктора Header, и из
167
177
  // addWC(), поэтому в set() слушатель ipcRenderer накапливался.
168
178
  #resizable_false_event = () => this.#maximize.remove();
169
- // Прокси окна берём один раз: getCurrentWindow() — синхронный IPC, а состояние
170
- // нужно и на resize. null — окно недоступно (безопасные окна живут без @electron/remote).
171
- #current_window = false;
179
+ // Номер запроса состояния гасит ответы, пришедшие не в том порядке
180
+ #sync_request = 0;
172
181
  constructor() {
173
182
  // Скрипт окна может быть preload'ом и выполняться до документа: здесь документ уже настоящий,
174
183
  // поэтому `dir`/`lang` (и зеркалирование логических свойств CSS) встают гарантированно
175
184
  I18n.apply();
176
- this.#setButton(this.#close, I18n.t("appLayout.window.close"), Icons.NAVIGATION.CLOSE);
177
- // Линию «свернуть» берём знак `horizontal_rule`, а не `minimize`: у того линия стоит
178
- // у нижнего края глифа (замер: центр чернил на 5.25px ниже центра кнопки), и кнопка
179
- // читалась смещённой; `horizontal_rule` — ровная линия по центру
180
- this.#setButton(this.#minimize, I18n.t("appLayout.window.minimize"), Icons.EDITOR.HORIZONTAL_RULE);
181
- // Глиф «развернуть» — квадрат (crop_square): в наборе есть и `maximize`, но там это
182
- // линия у верхнего края — визуально она неотличима от второй кнопки «свернуть»
183
- this.#setButton(this.#maximize, I18n.t("appLayout.window.maximize"), Icons.IMAGE.CROP_SQUARE);
184
- this.#close.addEventListener("click", () => {
185
- const window_ = this.#getWindow();
186
- if (window_ !== undefined) window_.close();
187
- })
188
- this.#minimize.addEventListener("click", () => {
189
- const window_ = this.#getWindow();
190
- if (window_ !== undefined) window_.minimize();
191
- })
192
- this.#maximize.addEventListener("click", () => {
193
- const window_ = this.#getWindow();
194
- if (window_ === undefined) return;
195
- window_.isMaximized() ? window_.unmaximize() : window_.maximize();
196
- })
185
+ this.#setButton(this.#close, I18n.t("appLayout.window.close"), "close");
186
+ // «Свернуть» — шеврон вниз: плоская широкая галочка читается лучше горизонтальной
187
+ // черты и не сливается с рамкой кнопки
188
+ this.#setButton(this.#minimize, I18n.t("appLayout.window.minimize"), "minimize");
189
+ // «Развернуть» — шеврон вверх, зеркало «свернуть»: пара читается как одна ось
190
+ this.#setButton(this.#maximize, I18n.t("appLayout.window.maximize"), "maximize");
191
+ this.#close.addEventListener("click", () => this.#windowAction("close"))
192
+ this.#minimize.addEventListener("click", () => this.#windowAction("minimize"))
193
+ // Разворот и восстановление — одно действие: решение принимает окно, а не кнопка
194
+ this.#maximize.addEventListener("click", () => this.#windowAction("toggle-maximize"))
197
195
  // Разворот приходит не только от нашей кнопки (двойной клик по полосе, меню окна),
198
196
  // поэтому состояние читаем по факту изменения размера окна
199
197
  window.addEventListener("resize", this.#sync_maximized_event)
200
198
  this.#syncMaximized()
201
199
  ipcRenderer.on("chui_resizable_false", this.#resizable_false_event)
202
200
  }
203
- /** Кнопка окна — иконка шапки с именем для скринридера, фокусом и активацией с клавиатуры.
204
- * Кегль глифа задаёт CSS (см. global_style.css): у кнопок окна он меньше, чем у соседних
205
- * иконок шапки, поэтому опция размера здесь не передаётся */
201
+ /** Кнопка окна — значок с именем для скринридера, фокусом и активацией с клавиатуры.
202
+ * Значок рисуется линиями (`WINDOW_ICONS`), размер задаёт CSS (см. global_style.css) */
206
203
  #setButton(button, label, icon) {
207
- button.innerHTML = new Icon(icon).getHTML();
204
+ button.innerHTML = WINDOW_ICONS[icon];
208
205
  button.setAttribute("title", label);
209
206
  button.setAttribute("aria-label", label);
210
207
  makeKeyboardButton(button);
211
208
  }
212
- #getWindow() {
213
- if (this.#current_window === false) {
214
- try {
215
- this.#current_window = require("@electron/remote").getCurrentWindow();
216
- } catch (_error) {
217
- this.#current_window = null;
218
- }
209
+ /**
210
+ * Действия кнопок окна идут по IPC в main (`WINDOW_ACTION`/`WINDOW_STATE`). Прежде здесь
211
+ * стоял `@electron/remote`, но он устарел и тянул модули main-процесса прямо в страницу.
212
+ * Обработчики берут окно отправителя, поэтому канал не может задеть чужое окно; ошибка
213
+ * глушится — без `Main` (обработчики не зарегистрированы) нажатие не должно ломать страницу.
214
+ */
215
+ #windowAction(action = String()) {
216
+ try {
217
+ return Promise.resolve(ipcRenderer.invoke(BRIDGE_CHANNELS.WINDOW_ACTION, action)).catch(() => false);
218
+ } catch (_error) {
219
+ return Promise.resolve(false);
220
+ }
221
+ }
222
+
223
+ /** Состояние окна: `{isMaximized, isMinimized, …}`; без окна — пустой объект */
224
+ async #windowState() {
225
+ try {
226
+ return (await ipcRenderer.invoke(BRIDGE_CHANNELS.WINDOW_STATE)) ?? {};
227
+ } catch (_error) {
228
+ return {};
219
229
  }
220
- return this.#current_window === null ? undefined : this.#current_window;
221
230
  }
222
- // Развёрнутое окно — другой глиф и другая подпись: кнопка возвращает окно в оконный режим.
223
- // Знак «восстановить» — те же два квадрата (`filter_none`); размер ему задаёт CSS
231
+ // Развёрнутое окно — другой значок и другая подпись: кнопка возвращает окно в оконный режим.
232
+ // «Восстановить» — контурный ромб; размер значкам задаёт CSS.
233
+ // Состояние приходит асинхронно, поэтому ответ устаревшего запроса отбрасывается по номеру:
234
+ // иначе быстрая пара «развернуть/восстановить» могла бы применить ответ в обратном порядке
224
235
  #syncMaximized() {
225
- const window_ = this.#getWindow();
226
- if (window_ === undefined) return;
227
- const maximized = window_.isMaximized();
228
- const label = maximized ? I18n.t("appLayout.window.restore") : I18n.t("appLayout.window.maximize");
229
- this.#maximize.classList.toggle("wc_maximized", maximized);
230
- this.#maximize.innerHTML = new Icon(maximized ? Icons.IMAGE.FILTER_NONE : Icons.IMAGE.CROP_SQUARE).getHTML();
231
- this.#maximize.setAttribute("title", label);
232
- this.#maximize.setAttribute("aria-label", label);
236
+ const request = ++this.#sync_request;
237
+ this.#windowState().then((state) => {
238
+ if (request !== this.#sync_request) return;
239
+ const maximized = state.isMaximized === true;
240
+ const label = maximized ? I18n.t("appLayout.window.restore") : I18n.t("appLayout.window.maximize");
241
+ this.#maximize.classList.toggle("wc_maximized", maximized);
242
+ this.#maximize.innerHTML = WINDOW_ICONS[maximized ? "restore" : "maximize"];
243
+ this.#maximize.setAttribute("title", label);
244
+ this.#maximize.setAttribute("aria-label", label);
245
+ });
233
246
  }
234
247
  #sync_maximized_event = () => this.#syncMaximized();
235
248
  set(pos_bool = Boolean()) {
@@ -454,15 +454,17 @@ header {
454
454
  display: flex;
455
455
  z-index: 999;
456
456
  flex-direction: column;
457
- /* Шапка — плоская полоса во всю ширину с волосяной чертой снизу: содержимое уходит
458
- под неё (материал полупрозрачный, с блюром). Углы прямые: скруглённые углы у полосы
459
- во всю ширину окна не читались — вверху их срезал бы композитор, а внизу они
460
- оставляли зазор. */
457
+ /* Шапка — плоская полоса во всю ширину: содержимое уходит под неё (материал
458
+ полупрозрачный, с блюром), низ скруглён и подчёркнут волосяной чертой. Радиус задан
459
+ логическими свойствами, поэтому в RTL скругление зеркалится; верхние углы прямые —
460
+ их срезал бы композитор. */
461
461
  align-items: start;
462
462
  background-color: var(--header_background);
463
463
  backdrop-filter: var(--global_blur);
464
464
  justify-content: space-between;
465
465
  border-bottom: 1px solid var(--border_color);
466
+ border-end-start-radius: var(--radius_md);
467
+ border-end-end-radius: var(--radius_md);
466
468
  }
467
469
 
468
470
  /* Прокрутка содержимого: материал плотнее и появляется тень — полоса читается
@@ -882,11 +884,14 @@ app_menu_empty {
882
884
  ниже содержимого, и длинное название выдавило бы кнопки за край окна. Полный текст
883
885
  лежит в подсказке (её ставит setPageName в chui_app_layout.js). */
884
886
  page_name {
885
- /* Название переехало в содержимое страницы (`page_title` в chui_page/styles.css):
886
- в шапке оно уезжало при прокрутке и делило полосу с кнопками. Узел оставлен
887
- якорем — за ним разметка ставит полосу меню (`applyPageMenuBar`), а `setPageName`
888
- продолжает держать текст для меню маршрутов и подсказки */
889
- display: none;
887
+ /* Название показывает шапка, а не содержимое: оболочка в страницу его не подмешивает
888
+ (`Route#go`), поэтому приложение, рисующее свой заголовок, ничего не дублирует.
889
+ Узел — якорь полосы меню (`applyPageMenuBar` ставит её следом), и он же держит текст
890
+ для меню маршрутов и подсказки (`setPageName`).
891
+ У страницы с меню-баром название скрыто — место занимает полоса (правило
892
+ `.header_with_menu_bar page_name` ниже) */
893
+ display: flex;
894
+ align-items: center;
890
895
  outline: none;
891
896
  box-sizing: border-box;
892
897
  min-width: 0;
@@ -1253,25 +1258,21 @@ wc_close, wc_maximize, wc_minimize {
1253
1258
  transition: background-color var(--transition_time) ease, color var(--transition_time) ease;
1254
1259
  }
1255
1260
 
1256
- /* Кегль глифов меньше соседних иконок шапки (--header_icon_size = 13pt): три системные
1257
- кнопки не должны перетягивать на себя внимание. Замер чернил при 14px: квадрат
1258
- 9.6×9.6, линия 9.6×1.3, крест 7.9×7.9 — у креста знак мельче, так он нарисован
1259
- в наборе. chui_icon задаёт собственный color, поэтому цвет кнопки до глифа не доходит —
1260
- правила состояний ниже тоже про иконку. */
1261
- wc_close chui_icon,
1262
- wc_maximize chui_icon,
1263
- wc_minimize chui_icon {
1264
- font-size: 14px;
1261
+ /* Значки кнопок окна нарисованы линиями (SVG), а не взяты из шрифта иконок: три системные
1262
+ кнопки должны выглядеть одинаково и тогда, когда Material Symbols недоступен (веб-сборка,
1263
+ безопасное окно без шрифта фреймворка). Все четыре значка вписаны в один квадрат 20×20 и
1264
+ центрированы геометрически, поэтому при общем размере 16 пикселей стоят ровно и читаются
1265
+ одной тройкой. Цвет приходит от кнопки через `currentColor`, поэтому состояния ниже красят
1266
+ сам `svg`. */
1267
+ wc_close svg,
1268
+ wc_maximize svg,
1269
+ wc_minimize svg {
1270
+ display: block;
1271
+ width: 16px;
1272
+ height: 16px;
1265
1273
  color: var(--text_color_disabled);
1266
1274
  }
1267
1275
 
1268
- /* «Восстановить» — два квадрата вместо одного: знак плотнее и при общем кегле выходил
1269
- крупнее одиночного квадрата (чернила 15×15 против 11×11). Меньший кегль выравнивает
1270
- боксы: 9.5×9.5 у «восстановить» против 9.6×9.6 у «развернуть» */
1271
- wc_maximize.wc_maximized chui_icon {
1272
- font-size: 10px;
1273
- }
1274
-
1275
1276
  wc_maximize:hover, wc_minimize:hover {
1276
1277
  background: var(--element_background_hover);
1277
1278
  }
@@ -1280,13 +1281,13 @@ wc_maximize:active, wc_minimize:active {
1280
1281
  background: var(--element_background_prime); color: var(--text_color_hover);
1281
1282
  }
1282
1283
 
1283
- wc_maximize:hover chui_icon,
1284
- wc_maximize:active chui_icon,
1285
- wc_minimize:hover chui_icon,
1286
- wc_minimize:active chui_icon,
1287
- wc_close:focus-visible chui_icon,
1288
- wc_maximize:focus-visible chui_icon,
1289
- wc_minimize:focus-visible chui_icon {
1284
+ wc_maximize:hover svg,
1285
+ wc_maximize:active svg,
1286
+ wc_minimize:hover svg,
1287
+ wc_minimize:active svg,
1288
+ wc_close:focus-visible svg,
1289
+ wc_maximize:focus-visible svg,
1290
+ wc_minimize:focus-visible svg {
1290
1291
  color: var(--text_color);
1291
1292
  }
1292
1293
 
@@ -1297,8 +1298,8 @@ wc_close:active {
1297
1298
  background: var(--red_prime_background);
1298
1299
  }
1299
1300
 
1300
- wc_close:hover chui_icon,
1301
- wc_close:active chui_icon {
1301
+ wc_close:hover svg,
1302
+ wc_close:active svg {
1302
1303
  color: var(--text_color);
1303
1304
  }
1304
1305