chuijs 4.0.1 → 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 CHANGED
@@ -3,6 +3,58 @@
3
3
  Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
4
4
  версии — [Semantic Versioning](https://semver.org/lang/ru/).
5
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
+
6
58
  ## [4.0.1] - 2026-09-28
7
59
 
8
60
  ### Добавлено
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,32 +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. **Только для main-процесса:** странице модули main не отдают
185
- (Electron не рекомендует `remote`), и её `App.get*` бросает ошибку с подсказкой. Со страницы
186
- берут `Desktop.app`, `Desktop.session`, `Desktop.dialog` или свой канал из `security.channels`.
187
+ Пути приложения отдаёт `Desktop.app.path(name)` — и в main-процессе, и на странице, значением
188
+ без промиса (на странице — синхронным каналом, в main — прямо у Electron):
187
189
 
188
190
  ```javascript
189
- const { App } = require('chuijs');
190
- App.get(); // app
191
- App.getSession(); // session
192
- 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');
193
195
  ```
194
196
 
195
- #### `App.*Path()`
196
- Статические системные пути (все возвращают строку). **Только для main-процесса;** со страницы —
197
- `await Desktop.app.path('userData')` (там путь приходит по IPC и потому асинхронно).
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()`:
198
205
 
199
206
  ```javascript
200
- const { App } = require('chuijs');
201
- App.userDataPath(); // .../AppData/<name>
202
- App.downloadsPath();
203
- App.logsPath();
207
+ const app = Desktop.app.get(); // main.js
208
+ app.on('session-created', (session) => { /* … */ });
204
209
  ```
205
210
 
206
- Полный список: `homePath()`, `appDataPath()`, `userDataPath()`, `sessionDataPath()`, `logsPath()`, `tempPath()`, `exePath()`, `modulePath()`, `desktopPath()`, `documentsPath()`, `downloadsPath()`, `musicPath()`, `picturesPath()`, `videosPath()`, `recentPath()`, `crashDumpsPath()`.
211
+ Со страницы эти методы бросают ошибку с подсказкой: вместо них берут `Desktop.session.*`,
212
+ `Desktop.dialog` или свой канал из `security.channels`.
207
213
 
208
214
  #### `Log`
209
215
  Пишет в `userData/logs/app_<date>.log`.
@@ -280,7 +286,8 @@ await bot.sendMessage({ chat_id: '123', text: 'привет' });
280
286
  доступны только там), а страница работает с ними через `Desktop`: он сам выбирает транспорт —
281
287
  канал `ipcRenderer.invoke` в обычном режиме или методы `window.chui.services` (preload +
282
288
  `contextBridge`) в безопасном. Имена каналов лежат в одном модуле `chui_services/chui_channels`,
283
- поэтому main и рендерер не расходятся.
289
+ поэтому main и рендерер не расходятся. Пути приложения — единственное исключение: они идут
290
+ синхронным каналом (`Desktop.app.path` отвечает значением, а не промисом, см. ниже).
284
291
 
285
292
  ```javascript
286
293
  const { Desktop } = require('chuijs');
@@ -302,8 +309,11 @@ offTheme();
302
309
  await Desktop.print.page(); // системный диалог печати текущего окна
303
310
  await Desktop.print.html('<h1>Отчёт</h1>'); // печать HTML из скрытого окна
304
311
  await Desktop.print.pdf(html, {path: '/tmp/отчёт.pdf'}); // PDF без диалога (или с ним, без path)
305
- await Desktop.app.path('userData'); // каталог настроек приложения
312
+ const userData = Desktop.app.path('userData'); // каталог настроек приложения, сразу
306
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(); // перезапуск процесса (состояние не переносится)
307
317
  const {canceled, filePaths} = await Desktop.dialog.open({properties: ['openFile']});
308
318
  await Desktop.dialog.save({defaultPath: '/tmp/отчёт.pdf'});
309
319
  await Desktop.dialog.message({type: 'question', message: 'Продолжить?'});
@@ -419,8 +429,10 @@ window.chui.desktop.clipboard.readText(); // то же, что Desktop из re
419
429
  window.chui.desktop.recent.list();
420
430
  window.chui.desktop.progress.set(0.4);
421
431
  const off = window.chui.desktop.shortcuts.on('Mod+Shift+P', () => {});
422
- await window.chui.desktop.app.path('userData'); // путь приложения — по IPC, потому асинхронно
432
+ window.chui.desktop.app.path('userData'); // путь приложения — сразу, без промиса
423
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(); // перезапустить приложение (тоже без промиса)
424
436
  const res = await window.chui.desktop.dialog.open({properties: ['openFile']});
425
437
  await window.chui.desktop.dialog.message({type: 'question', message: 'Продолжить?'});
426
438
  await window.chui.desktop.session.clearCache();
@@ -644,7 +656,7 @@ DOM-элемент, поэтому внутри `Page.add(...)`, `Card.add(...)`
644
656
  [Константы и статические фабрики](docs/topics/constants.html), [Сценарии целиком](docs/topics/scenarios.html),
645
657
  [Витрина компонентов](docs/topics/showcase.html).
646
658
 
647
- Модули и десктоп-сервисы (`Main`, `App`, `I18n`, `Theme`, `Desktop`, `Log`) описаны выше, в разделе «Модули».
659
+ Модули и десктоп-сервисы (`Main`, `I18n`, `Theme`, `Desktop`, `Log`) описаны выше, в разделе «Модули».
648
660
 
649
661
  ### В разработке
650
662
 
@@ -89,19 +89,12 @@ class Route extends Events {
89
89
  //
90
90
  setPageName(page.getTitle());
91
91
  center.innerHTML = '';
92
- const page_node = page.render();
93
- // Заголовок страницы — в содержимом, первым блоком (вид — `page_title` в
94
- // chui_page/styles.css). Текст идёт текстовым узлом, а не разметкой: имя
95
- // страницы приходит от приложения и могло бы принести теги
96
- const title = page.getTitle();
97
- if (title !== undefined && title !== null && String(title) !== "") {
98
- const block = document.createElement("page_title");
99
- const icon = page.getIcon();
100
- if (icon !== undefined && icon !== null) block.innerHTML = String(icon);
101
- block.appendChild(document.createTextNode(String(title)));
102
- page_node.prepend(block);
103
- }
104
- center.appendChild(page_node);
92
+ // Содержимое собирает сама страница (`Page#render`), оболочка в него не подмешивает
93
+ // ничего: узел `page_title` (был здесь) дублировал заголовок, который страница
94
+ // рисует сама, а `Page#render` отдаёт один и тот же узел — повторный переход на тот
95
+ // же экземпляр страницы (кнопка «Назад» из `Page#setBackButton`) добавлял второй
96
+ // заголовок. Место названия — шапка (`setPageName`).
97
+ center.appendChild(page.render());
105
98
  const _page = center.querySelector('page');
106
99
  new Animation(_page).fadeIn();
107
100
  // Стирания `center.removeAttribute("style")` здесь больше нет: инлайновых
@@ -884,11 +884,14 @@ app_menu_empty {
884
884
  ниже содержимого, и длинное название выдавило бы кнопки за край окна. Полный текст
885
885
  лежит в подсказке (её ставит setPageName в chui_app_layout.js). */
886
886
  page_name {
887
- /* Название переехало в содержимое страницы (`page_title` в chui_page/styles.css):
888
- в шапке оно уезжало при прокрутке и делило полосу с кнопками. Узел оставлен
889
- якорем — за ним разметка ставит полосу меню (`applyPageMenuBar`), а `setPageName`
890
- продолжает держать текст для меню маршрутов и подсказки */
891
- 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;
892
895
  outline: none;
893
896
  box-sizing: border-box;
894
897
  min-width: 0;
@@ -1,5 +1,5 @@
1
1
  const {contextBridge, ipcRenderer} = require("electron");
2
- const {SERVICES_CHANNELS, SERVICES_CALLS, BRIDGE_CHANNELS, BRIDGE_ARGS} = require("../chui_services/chui_channels");
2
+ const {SERVICES_CHANNELS, SERVICES_CALLS, SERVICES_SYNC_CALLS, BRIDGE_CHANNELS, BRIDGE_ARGS} = require("../chui_services/chui_channels");
3
3
  // Мост переиспользует ту же обёртку сервисов, что и страница: разница только в транспорте
4
4
  // (`Desktop` в preload'е сам вызовет ipcRenderer, потому что `window.chui` ему не виден)
5
5
  const {Desktop} = require("../chui_services/desktop");
@@ -16,8 +16,10 @@ const {Desktop} = require("../chui_services/desktop");
16
16
  * файл при `DOMContentLoaded`.
17
17
  *
18
18
  * Формы API:
19
- * - `window.chui.desktop.{clipboard, recent, progress, shortcuts}` — то же, что `Desktop` из
19
+ * - `window.chui.desktop.{app, clipboard, recent, progress, shortcuts}` — то же, что `Desktop` из
20
20
  * `require('chuijs')`, поэтому код страницы переносится между режимами почти без правок;
21
+ * `desktop.app.quit()`/`restart()` закрывают и перезапускают всё приложение, а не окно; ответ
22
+ * у них значением, без промиса (синхронный канал);
21
23
  * - `window.chui.services` — плоская карта «метод → канал» (транспорт, который ищет `Desktop`);
22
24
  * - `window.chui.window` — минимизировать, развернуть, закрыть, состояние окна;
23
25
  * - `window.chui.theme` — текущая тема и подписка на её смену;
@@ -74,6 +76,12 @@ const services = {};
74
76
  for (const method of Object.keys(SERVICES_CALLS)) {
75
77
  services[method] = (...args) => call(method, ...args);
76
78
  }
79
+ // Синхронные вызовы — отдельным циклом и через `sendSync`: на синхронный канал `invoke`
80
+ // не отвечает, промис никогда бы не завершился. Конверт `{value}`/`{error}` разбирает
81
+ // `Desktop.#callSync`, поэтому мост отдаёт ответ как есть — как и обычный канал
82
+ for (const [method, channel] of Object.entries(SERVICES_SYNC_CALLS)) {
83
+ services[method] = (...args) => ipcRenderer.sendSync(channel, ...args);
84
+ }
77
85
  // Событие глобальной клавиши: подписка возвращает функцию отписки
78
86
  services.onShortcut = (handler = () => {}) => {
79
87
  const listener = (_event, accelerator) => handler(accelerator);
@@ -128,8 +136,19 @@ contextBridge.exposeInMainWorld("chui", {
128
136
  openExternal: (url = String()) => Desktop.shell.openExternal(url)
129
137
  },
130
138
  app: {
139
+ // Путь приходит значением, без промиса (синхронный канал), а `Desktop` в preload'е
140
+ // идёт в него сам: `window.chui` своей же странице здесь ещё не виден
131
141
  path: (name = String()) => Desktop.app.path(name),
132
- info: () => Desktop.app.info()
142
+ info: () => Desktop.app.info(),
143
+ // Сырые модули Electron странице не отдают. Методы есть для единообразия
144
+ // с `Desktop`, но со страницы бросают ошибку с подсказкой, что взять взамен
145
+ get: () => Desktop.app.get(),
146
+ getSession: () => Desktop.app.getSession(),
147
+ getWebContents: () => Desktop.app.getWebContents(),
148
+ // Закрытие и перезапуск — как у `Desktop` из пакета: те же шаги, что у пункта меню.
149
+ // Отвечают значением, а не промисом (синхронный канал), поэтому `await` не нужен
150
+ quit: () => Desktop.app.quit(),
151
+ restart: () => Desktop.app.restart()
133
152
  },
134
153
  dialog: {
135
154
  open: (options = {}) => Desktop.dialog.open(options),
@@ -9,29 +9,6 @@ page {
9
9
  align-items: baseline;
10
10
  }
11
11
 
12
- /* Заголовок страницы живёт в содержимом, а не капсулой в шапке: в полосе он уезжал
13
- при прокрутке и при этом занимал место рядом с кнопками. Ставит его оболочка
14
- (`Route#go`), текст и значок берёт из `Page#getTitle` / `Page#getIcon` */
15
- page_title {
16
- display: flex;
17
- align-items: center;
18
- gap: 10px;
19
- /* Без собственных отступов: text заголовка стоит по тому же вертикали, что и блоки
20
- ниже, — иначе он выезжает на 2px вправо и левый край страницы ломается */
21
- margin: 0 0 calc(10px * var(--density)) 0;
22
- padding: 0;
23
- font-size: var(--font_lead_size);
24
- font-weight: 600;
25
- color: var(--text_color);
26
- }
27
-
28
- page_title chui_icon {
29
- color: var(--link_color);
30
- /* Кегль значка — как у подписи: при 20px глиф перетягивал строку */
31
- font-size: var(--font_lead_size);
32
- }
33
-
34
- /* Страница без заголовка не держит пустую строку */
35
- page_title:empty {
36
- display: none;
37
- }
12
+ /* Вид заголовка страницы держит шапка (`page_name` в global_style.css): оболочка
13
+ в содержимое ничего не подмешивает (`Route#go`), поэтому страница начинается
14
+ со своих блоков, а видимый заголовок рисует приложение само */
@@ -43,8 +43,32 @@ const SERVICES_CHANNELS = {
43
43
  SHORTCUT_EVENT: "chui_shortcut",
44
44
  /** Путь приложения по имени из `APP_PATH_NAMES` */
45
45
  APP_PATH: "chui_services_app_path",
46
+ /**
47
+ * Тот же путь, но ответ приходит значением, а не промисом: канал синхронный
48
+ * (`ipcRenderer.sendSync`). `Desktop.app.path` ходит сюда — путь берут на старте, чтобы
49
+ * собрать свои пути к файлам, и ждать промис там негде. Асинхронный `APP_PATH` оставлен:
50
+ * карта `window.chui.services` отдаётся странице целиком, её ключи — часть API безопасного
51
+ * режима, и убрать один из них значило бы сломать чужой вызов.
52
+ */
53
+ APP_PATH_SYNC: "chui_services_app_path_sync",
46
54
  /** Сведения о приложении: имя, версия, язык, сборка */
47
55
  APP_INFO: "chui_services_app_info",
56
+ /**
57
+ * Закрыть и перезапустить приложение со страницы. Идут через `Main.stop()`/`restart()`:
58
+ * это те же шаги, что у пункта меню «Выход», — снимаются обработчики сервисов, глобальные
59
+ * клавиши и подписки, а не просто закрывается окно.
60
+ */
61
+ APP_QUIT: "chui_services_app_quit",
62
+ APP_RESTART: "chui_services_app_restart",
63
+ /**
64
+ * Те же закрытие и перезапуск, но ответ приходит значением, а не промисом: канал
65
+ * синхронный (`ipcRenderer.sendSync`). `Desktop.app.quit()`/`restart()` ходят сюда — выход
66
+ * начинается тем же `Main.stop()`/`restart()`, а страница получает ответ сразу, без `await`.
67
+ * Асинхронные `APP_QUIT`/`APP_RESTART` оставлены: их ключи есть в карте `window.chui.services`,
68
+ * а это часть API безопасного режима.
69
+ */
70
+ APP_QUIT_SYNC: "chui_services_app_quit_sync",
71
+ APP_RESTART_SYNC: "chui_services_app_restart_sync",
48
72
  /** Системные диалоги: окно берётся у отправителя */
49
73
  DIALOG_OPEN: "chui_services_dialog_open",
50
74
  DIALOG_SAVE: "chui_services_dialog_save",
@@ -99,6 +123,8 @@ const SERVICES_CALLS = {
99
123
  localeSet: SERVICES_CHANNELS.LOCALE_SET,
100
124
  appPath: SERVICES_CHANNELS.APP_PATH,
101
125
  appInfo: SERVICES_CHANNELS.APP_INFO,
126
+ appQuit: SERVICES_CHANNELS.APP_QUIT,
127
+ appRestart: SERVICES_CHANNELS.APP_RESTART,
102
128
  dialogOpen: SERVICES_CHANNELS.DIALOG_OPEN,
103
129
  dialogSave: SERVICES_CHANNELS.DIALOG_SAVE,
104
130
  dialogMessage: SERVICES_CHANNELS.DIALOG_MESSAGE,
@@ -107,6 +133,30 @@ const SERVICES_CALLS = {
107
133
  sessionClearStorage: SERVICES_CHANNELS.SESSION_CLEAR_STORAGE
108
134
  };
109
135
 
136
+ /**
137
+ * Синхронные вызовы (`ipcRenderer.sendSync`) — те, где ответ нужен значением, а не промисом.
138
+ *
139
+ * Отдельный map, а не строка в `SERVICES_CALLS`: тот мост превращает в методы страницы через
140
+ * `ipcRenderer.invoke`, а на синхронный канал invoke не отвечает — промис не завершился бы
141
+ * никогда, и страница молча ждала бы ответа.
142
+ *
143
+ * Ответ синхронного канала — конверт `{value}` или `{error}`: у `sendSync` нет канала для
144
+ * исключения, и без конверта причина отказа (имя пути вне белого списка) потерялась бы,
145
+ * а страница получила бы `undefined`. Разбирает конверт `Desktop.#callSync` — одинаково
146
+ * в обычном режиме и в безопасном.
147
+ *
148
+ * `sendSync` останавливает страницу на время ответа (main при этом свободен), поэтому
149
+ * синхронных вызовов должно быть мало и отвечать они обязаны мгновенно: `app.getPath` —
150
+ * чтение кеша Electron, без обращения к диску.
151
+ */
152
+ const SERVICES_SYNC_CALLS = {
153
+ appPathSync: SERVICES_CHANNELS.APP_PATH_SYNC,
154
+ // Закрытие и перезапуск отвечают мгновенно (`setImmediate` в main откладывает выход),
155
+ // поэтому синхронный канал здесь так же уместен, как у пути: странице не нужен `await`
156
+ appQuitSync: SERVICES_CHANNELS.APP_QUIT_SYNC,
157
+ appRestartSync: SERVICES_CHANNELS.APP_RESTART_SYNC
158
+ };
159
+
110
160
  /** Каналы моста: окно, тема и прикладные сообщения безопасного окна */
111
161
  const BRIDGE_CHANNELS = {
112
162
  WINDOW_ACTION: "chui_bridge_window_action",
@@ -128,6 +178,7 @@ const BRIDGE_ARGS = {
128
178
 
129
179
  exports.SERVICES_CHANNELS = SERVICES_CHANNELS
130
180
  exports.SERVICES_CALLS = SERVICES_CALLS
181
+ exports.SERVICES_SYNC_CALLS = SERVICES_SYNC_CALLS
131
182
  exports.BRIDGE_CHANNELS = BRIDGE_CHANNELS
132
183
  exports.APP_PATH_NAMES = APP_PATH_NAMES
133
184
  exports.BRIDGE_ARGS = BRIDGE_ARGS
@@ -1,4 +1,4 @@
1
- const {SERVICES_CHANNELS, SERVICES_CALLS} = require("./chui_channels");
1
+ const {SERVICES_CHANNELS, SERVICES_CALLS, SERVICES_SYNC_CALLS, APP_PATH_NAMES} = require("./chui_channels");
2
2
 
3
3
  /**
4
4
  * Десктоп-сервисы со стороны страницы: буфер обмена, последние документы, прогресс на иконке
@@ -126,19 +126,75 @@ class Desktop {
126
126
  };
127
127
 
128
128
  /**
129
- * Приложение со стороны страницы: пути и сведения. Прямого доступа к модулям Electron
130
- * у страницы нет — это и есть рекомендация Electron вместо `remote`.
129
+ * Приложение: пути, сведения и сырые модули Electron.
130
+ *
131
+ * Странице модули main не отдают — это и есть рекомендация Electron вместо `remote`,
132
+ * поэтому пути и сведения идут по IPC. В main-процессе IPC нет, и те же методы отвечают
133
+ * значением: `path()` берёт путь у Electron, а `get()`/`getSession()`/`getWebContents()`
134
+ * отдают сами объекты.
131
135
  *
132
136
  * ```javascript
133
- * await Desktop.app.path('userData'); // каталог настроек приложения
134
- * await Desktop.app.info(); // {name, version, locale, isPackaged}
137
+ * const dir = Desktop.app.path('userData'); // путь приходит сразу, без промиса
138
+ * const info = await Desktop.app.info(); // {name, version, locale, isPackaged}
139
+ * Desktop.app.quit(); // закрыть приложение — тоже без промиса
135
140
  * ```
136
141
  */
137
142
  static app = {
138
- /** Путь по имени из белого списка (`"userData"`, `"downloads"`, …) */
139
- path: (name = String()) => Desktop.#call("appPath", String(name)),
143
+ /**
144
+ * Путь по имени из белого списка (`"userData"`, `"downloads"`, …).
145
+ *
146
+ * Отвечает значением, а не промисом: из этих путей страница собирает свои (файлы,
147
+ * логи, кеш) на старте, и `await` пришлось бы протаскивать через весь код инициализации.
148
+ * Идёт по синхронному каналу (`SERVICES_SYNC_CALLS`), а потому значение берётся один раз
149
+ * на имя и кешируется — `sendSync` останавливает страницу на время ответа, а путь за
150
+ * жизнь приложения не меняется (`app.setPath` в main — редкий случай, и тогда кеш
151
+ * страницы устареет так же, как устарел бы вызов, сделанный раньше).
152
+ * Прежний `await Desktop.app.path(...)` продолжает работать: `await` на строке
153
+ * возвращает ту же строку.
154
+ *
155
+ * Работает и в main-процессе: там канала нет, и путь берётся у Electron напрямую
156
+ * (с тем же белым списком), поэтому `main.js` обходится без отдельного класса путей.
157
+ */
158
+ path: (name = String()) => Desktop.#appPath(String(name)),
140
159
  /** @returns {Promise<{name: string, version: string, locale: string, systemLocale: string, isPackaged: boolean}>} */
141
- info: () => Desktop.#call("appInfo")
160
+ info: () => Desktop.#call("appInfo"),
161
+ /**
162
+ * Сырой объект `app` Electron. **Только для main-процесса:** странице модули main
163
+ * не отдают, и здесь она получит ошибку с подсказкой — вместо `app` со страницы берут
164
+ * `Desktop.app.info()`, `Desktop.session.*`, `Desktop.dialog` или свой канал.
165
+ *
166
+ * ```javascript
167
+ * const app = Desktop.app.get(); // main.js
168
+ * app.on('session-created', (session) => {});
169
+ * ```
170
+ */
171
+ get: () => Desktop.#electronObject("app"),
172
+ /** Сырой `session` Electron (только main-процесс; странице — `Desktop.session.*`) */
173
+ getSession: () => Desktop.#electronObject("session"),
174
+ /** Сырой `webContents` Electron (только main-процесс) */
175
+ getWebContents: () => Desktop.#electronObject("webContents"),
176
+ /**
177
+ * Закрыть приложение: тот же выход, что у пункта меню «Выход» — `Main.stop()` снимает
178
+ * обработчики сервисов, глобальные клавиши и подписки, а затем приложение выходит.
179
+ *
180
+ * Отвечает значением, а не промисом (синхронный канал): ждать результат не нужно и
181
+ * `await` ничего не меняет — `await true` даёт то же `true`. Ответ приходит до выхода,
182
+ * потому что main откладывает `stop()` через `setImmediate`.
183
+ *
184
+ * ```javascript
185
+ * new Button({title: 'Выход', clickEvent: () => Desktop.app.quit()});
186
+ * ```
187
+ * @returns {boolean} `true` — выход начат
188
+ */
189
+ quit: () => Desktop.#callSync("appQuitSync"),
190
+ /**
191
+ * Перезапустить приложение: процесс поднимается заново (`Main.restart()` →
192
+ * `app.relaunch()`), поэтому состояние из памяти не переносится — сохраните его сами.
193
+ *
194
+ * Отвечает значением, а не промисом (синхронный канал), как и `quit()`.
195
+ * @returns {boolean} `true` — перезапуск начат
196
+ */
197
+ restart: () => Desktop.#callSync("appRestartSync")
142
198
  };
143
199
 
144
200
  /**
@@ -183,6 +239,9 @@ class Desktop {
183
239
  static #history_handlers = new Set();
184
240
  static #history_event_bound = false;
185
241
  static #history_bridge_unsubscribe = undefined;
242
+ // Пути приложения, уже полученные значением: `sendSync` останавливает страницу на время
243
+ // ответа, поэтому за одним и тем же именем в main ходят один раз
244
+ static #app_paths = new Map();
186
245
 
187
246
  static #bridge() {
188
247
  if (typeof window === "undefined" || window.chui === undefined) return undefined;
@@ -197,6 +256,65 @@ class Desktop {
197
256
  return ipcRenderer.invoke(SERVICES_CALLS[method], ...args);
198
257
  }
199
258
 
259
+ /**
260
+ * Синхронный вызов сервиса: в безопасном режиме — метод моста, иначе — канал `sendSync`.
261
+ * Канал отвечает конвертом `{value}`/`{error}` (см. `SERVICES_SYNC_CALLS` в chui_channels.js),
262
+ * поэтому исключение из main превращается здесь в обычную ошибку с его текстом.
263
+ */
264
+ static #callSync(method = String(), ...args) {
265
+ const services = Desktop.#bridge();
266
+ const answer = services !== undefined && typeof services[method] === "function"
267
+ ? services[method](...args)
268
+ : require("electron").ipcRenderer.sendSync(SERVICES_SYNC_CALLS[method], ...args);
269
+ if (answer === null || typeof answer !== "object") return answer;
270
+ if (answer.error !== undefined) throw new Error(answer.error);
271
+ return answer.value;
272
+ }
273
+
274
+ /**
275
+ * Путь приложения значением (см. `Desktop.app.path`).
276
+ *
277
+ * В main-процессе IPC недоступен (`ipcRenderer` там нет, а отправителя-страницы не
278
+ * существует), поэтому путь берётся у Electron напрямую — с той же проверкой белого
279
+ * списка, что и в канале. Это позволяет звать `Desktop.app.path()` из `main.js` и со
280
+ * страницы одним и тем же вызовом.
281
+ */
282
+ static #appPath(name = String()) {
283
+ if (Desktop.#app_paths.has(name)) return Desktop.#app_paths.get(name);
284
+ const value = Desktop.#inMainProcess()
285
+ ? Desktop.#appPathInMain(name)
286
+ : Desktop.#callSync("appPathSync", name);
287
+ Desktop.#app_paths.set(name, value);
288
+ return value;
289
+ }
290
+
291
+ /** Различает процессы: у main нет `window`, у страницы и preload'а — есть */
292
+ static #inMainProcess() {
293
+ return typeof window === "undefined";
294
+ }
295
+
296
+ /** main-процесс: путь у Electron напрямую, но только из белого списка */
297
+ static #appPathInMain(name = String()) {
298
+ if (APP_PATH_NAMES.includes(name) === false) {
299
+ throw new Error(`Desktop.app.path: имя «${name}» вне белого списка`);
300
+ }
301
+ return require("electron").app.getPath(name);
302
+ }
303
+
304
+ /**
305
+ * Сырой объект Electron для main-процесса.
306
+ *
307
+ * Со страницы `app`/`session`/`webContents` недоступны: Electron не рекомендует `remote`,
308
+ * а мост preload'а отдаёт только узкий `window.chui`. Поэтому вместо тихого `undefined`
309
+ * здесь ошибка с подсказкой, что взять взамен.
310
+ */
311
+ static #electronObject(name = String()) {
312
+ if (Desktop.#inMainProcess() === false) {
313
+ throw new Error(`Desktop.app: сырой «${name}» доступен только в main-процессе — со страницы возьмите Desktop.app.info(), Desktop.session.* или свой канал из security.channels`);
314
+ }
315
+ return require("electron")[name];
316
+ }
317
+
200
318
  static #subscribe(accelerator = String(), handler = () => {}) {
201
319
  const handlers = Desktop.#shortcut_handlers.get(accelerator) ?? new Set();
202
320
  const first = handlers.size === 0;
package/index.js CHANGED
@@ -155,6 +155,18 @@ function inRenderer() {
155
155
  return process !== undefined && process.type === "renderer";
156
156
  }
157
157
 
158
+ /**
159
+ * Путь приложения по имени из белого списка.
160
+ *
161
+ * Одно правило на два канала (`APP_PATH` и `APP_PATH_SYNC`): странице не должен быть доступен
162
+ * произвольный каталог системы, поэтому всё, что вне `APP_PATH_NAMES`, main отклоняет. Сам
163
+ * `app.getPath` синхронный и без диска — именно поэтому путь можно отдавать значением.
164
+ */
165
+ function appPathByName(name = String()) {
166
+ if (APP_PATH_NAMES.includes(name) === false) throw new Error(`Desktop.app.path: имя «${name}» вне белого списка`);
167
+ return app.getPath(name);
168
+ }
169
+
158
170
  //VARS
159
171
  let isQuiting = false;
160
172
 
@@ -233,6 +245,9 @@ class Main {
233
245
  // Десктоп-сервисы (буфер, недавние, прогресс, глобальные клавиши) и их подписки по окнам
234
246
  #services_enabled = false;
235
247
  #service_handlers = [];
248
+ // Синхронные каналы снимаются не `removeHandler`, а `removeListener`: они зарегистрированы
249
+ // через `ipcMain.on` — см. `#handleSync`
250
+ #service_sync_handlers = [];
236
251
  #shortcut_subscribers = new Map();
237
252
  #shortcut_watchers = new Map();
238
253
  #progress_windows = new Map();
@@ -695,10 +710,11 @@ class Main {
695
710
  });
696
711
  // Приложение и системные диалоги для страницы: Electron советует IPC вместо `remote`.
697
712
  // Путь — только из белого списка, диалог и сессия — у окна-отправителя.
698
- this.#handle(SERVICES_CHANNELS.APP_PATH, (event, name) => {
699
- if (APP_PATH_NAMES.includes(name) === false) throw new Error(`App.path: имя «${name}» вне белого списка`);
700
- return app.getPath(name);
701
- });
713
+ this.#handle(SERVICES_CHANNELS.APP_PATH, (event, name) => appPathByName(name));
714
+ // `Desktop.app.path` отвечает значением, а не промисом, поэтому у пути есть второй,
715
+ // синхронный канал: `sendSync` ждёт `event.returnValue`, а не ответ `handle`.
716
+ // Асинхронный канал выше оставлен — его ключ есть в карте `window.chui.services`
717
+ this.#handleSync(SERVICES_CHANNELS.APP_PATH_SYNC, (event, name) => appPathByName(name));
702
718
  this.#handle(SERVICES_CHANNELS.APP_INFO, () => ({
703
719
  name: app.getName(),
704
720
  version: app.getVersion(),
@@ -706,6 +722,31 @@ class Main {
706
722
  systemLocale: app.getSystemLocale(),
707
723
  isPackaged: app.isPackaged
708
724
  }));
725
+ // Закрытие и перезапуск со страницы: страница — код самого приложения, поэтому кнопке
726
+ // «Выход» не нужен свой канал в каждом проекте. Идут через `Main.stop()`/`restart()` —
727
+ // те же шаги, что у пункта меню: снимаются обработчики сервисов, глобальные клавиши и
728
+ // подписки, а не просто закрывается окно. Ответ уходит странице до выхода
729
+ // (`setImmediate`): иначе промис отваливался бы с «Render frame was disposed», и
730
+ // `await Desktop.app.quit()` со страницы выглядел бы ошибкой
731
+ this.#handle(SERVICES_CHANNELS.APP_QUIT, () => {
732
+ setImmediate(() => this.stop());
733
+ return true;
734
+ });
735
+ this.#handle(SERVICES_CHANNELS.APP_RESTART, () => {
736
+ setImmediate(() => this.restart());
737
+ return true;
738
+ });
739
+ // У закрытия и перезапуска есть и синхронные каналы: `Desktop.app.quit()`/`restart()`
740
+ // отвечают значением, а не промисом — как `Desktop.app.path`. Выход по-прежнему
741
+ // откладывается `setImmediate`, чтобы `event.returnValue` успел уйти странице.
742
+ this.#handleSync(SERVICES_CHANNELS.APP_QUIT_SYNC, () => {
743
+ setImmediate(() => this.stop());
744
+ return true;
745
+ });
746
+ this.#handleSync(SERVICES_CHANNELS.APP_RESTART_SYNC, () => {
747
+ setImmediate(() => this.restart());
748
+ return true;
749
+ });
709
750
  this.#handle(SERVICES_CHANNELS.DIALOG_OPEN, (event, options) => Main.#showDialog(event, "showOpenDialog", options));
710
751
  this.#handle(SERVICES_CHANNELS.DIALOG_SAVE, (event, options) => Main.#showDialog(event, "showSaveDialog", options));
711
752
  this.#handle(SERVICES_CHANNELS.DIALOG_MESSAGE, (event, options) => Main.#showDialog(event, "showMessageBox", options));
@@ -767,6 +808,25 @@ class Main {
767
808
  return this;
768
809
  }
769
810
 
811
+ /**
812
+ * Синхронный канал: `ipcRenderer.sendSync` в странице ждёт `event.returnValue`, а не ответ
813
+ * `handle`, поэтому здесь `ipcMain.on`. Ответ — конверт `{value}`/`{error}`: у синхронного
814
+ * канала нет способа передать исключение, и без конверта страница получила бы `undefined`
815
+ * вместо причины отказа (см. `SERVICES_SYNC_CALLS` в chui_channels.js).
816
+ */
817
+ #handleSync(channel = String(), handler = () => {}) {
818
+ const listener = (event, ...args) => {
819
+ try {
820
+ event.returnValue = {value: handler(event, ...args)};
821
+ } catch (error) {
822
+ event.returnValue = {error: error instanceof Error ? error.message : String(error)};
823
+ }
824
+ };
825
+ ipcMain.on(channel, listener);
826
+ this.#service_sync_handlers.push([channel, listener]);
827
+ return this;
828
+ }
829
+
770
830
  /**
771
831
  * Включает мост безопасных окон (`contextBridge`): управление окном, тема и прикладные
772
832
  * сообщения по белому списку каналов. Вызывается сам при создании безопасного окна.
@@ -1051,6 +1111,8 @@ class Main {
1051
1111
  // Десктоп-сервисы: снимаем обработчики IPC, подписки клавиш и сами клавиши
1052
1112
  for (const [channel] of this.#service_handlers) ipcMain.removeHandler(channel);
1053
1113
  this.#service_handlers = [];
1114
+ for (const [channel, listener] of this.#service_sync_handlers) ipcMain.removeListener(channel, listener);
1115
+ this.#service_sync_handlers = [];
1054
1116
  this.#shortcut_subscribers.clear();
1055
1117
  this.#shortcut_watchers.clear();
1056
1118
  this.#progress_windows.clear();
@@ -1210,44 +1272,6 @@ class Styles {
1210
1272
  };
1211
1273
  }
1212
1274
 
1213
- class Application {
1214
- getApp() {
1215
- if (inRenderer()) throw new Error("App.get(): странице не отдают модули main — возьмите Desktop.app.path()/info() или свой канал из security.channels");
1216
- return app;
1217
- }
1218
- getSession() {
1219
- if (inRenderer()) throw new Error("App.getSession(): странице не отдают сессию main — возьмите Desktop.session.*");
1220
- return session;
1221
- }
1222
- getWebContents() {
1223
- if (inRenderer()) throw new Error("App.getWebContents(): странице не отдают webContents main — действия идут через Desktop или свой канал из security.channels");
1224
- return webContents;
1225
- }
1226
- }
1227
-
1228
- class App {
1229
- static get() { return new Application().getApp() }
1230
- static getSession() { return new Application().getSession() }
1231
- static getWebContents() { return new Application().getWebContents() }
1232
- // ПУТИ
1233
- static homePath() { return new Application().getApp().getPath("home") }
1234
- static appDataPath() { return new Application().getApp().getPath("appData") }
1235
- static userDataPath() { return new Application().getApp().getPath("userData") }
1236
- static sessionDataPath() { return new Application().getApp().getPath("sessionData") }
1237
- static logsPath() { return new Application().getApp().getPath("logs") }
1238
- static tempPath() { return new Application().getApp().getPath("temp") }
1239
- static exePath() { return new Application().getApp().getPath("exe") }
1240
- static modulePath() { return new Application().getApp().getPath("module") }
1241
- static desktopPath() { return new Application().getApp().getPath("desktop") }
1242
- static documentsPath() { return new Application().getApp().getPath("documents") }
1243
- static downloadsPath() { return new Application().getApp().getPath("downloads") }
1244
- static musicPath() { return new Application().getApp().getPath("music") }
1245
- static picturesPath() { return new Application().getApp().getPath("pictures") }
1246
- static videosPath() { return new Application().getApp().getPath("videos") }
1247
- static recentPath() { return new Application().getApp().getPath("recent") }
1248
- static crashDumpsPath() { return new Application().getApp().getPath("crashDumps") }
1249
- }
1250
-
1251
1275
  module.exports = {
1252
1276
  Main: Main,
1253
1277
  sleep: sleep,
@@ -1405,8 +1429,6 @@ module.exports = {
1405
1429
  transliterate: transliterate,
1406
1430
  store: store,
1407
1431
  //
1408
- App: App,
1409
- //
1410
1432
  // Десктоп-сервисы: классы main-процесса и доступ из страницы
1411
1433
  Shortcuts: Shortcuts,
1412
1434
  Shell: Shell,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "chuijs",
3
3
  "productName": "chuijs",
4
4
  "description": "component framework chUiJS",
5
- "version": "4.0.1",
5
+ "version": "4.0.2",
6
6
  "private": false,
7
7
  "main": "index.js",
8
8
  "type": "commonjs",