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.
@@ -40,9 +40,55 @@ const SERVICES_CHANNELS = {
40
40
  /** Событие main → рендерер: язык приложения изменился */
41
41
  LOCALE_EVENT: "chui_services_locale_event",
42
42
  /** Событие main → рендерер: сработала глобальная клавиша */
43
- SHORTCUT_EVENT: "chui_shortcut"
43
+ SHORTCUT_EVENT: "chui_shortcut",
44
+ /** Путь приложения по имени из `APP_PATH_NAMES` */
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",
54
+ /** Сведения о приложении: имя, версия, язык, сборка */
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",
72
+ /** Системные диалоги: окно берётся у отправителя */
73
+ DIALOG_OPEN: "chui_services_dialog_open",
74
+ DIALOG_SAVE: "chui_services_dialog_save",
75
+ DIALOG_MESSAGE: "chui_services_dialog_message",
76
+ /** Сессия окна-отправителя: кеш и хранилище */
77
+ SESSION_CACHE_SIZE: "chui_services_session_cache_size",
78
+ SESSION_CLEAR_CACHE: "chui_services_session_clear_cache",
79
+ SESSION_CLEAR_STORAGE: "chui_services_session_clear_storage"
44
80
  };
45
81
 
82
+ /**
83
+ * Имена путей `app.getPath()`, которые может запросить страница. Это белый список: всё, что
84
+ * вне него, main отклоняет — странице не должен быть доступен произвольный каталог системы.
85
+ * Имена совпадают с именами Electron, кроме `logs` (в Electron это `logs`).
86
+ */
87
+ const APP_PATH_NAMES = [
88
+ "home", "appData", "userData", "sessionData", "temp", "exe", "module",
89
+ "desktop", "documents", "downloads", "music", "pictures", "videos", "logs", "recent", "crashDumps"
90
+ ];
91
+
46
92
  /**
47
93
  * Метод моста preload'а → канал. `Desktop` вызывает метод по имени: в безопасном режиме
48
94
  * его берут из `window.chui.services`, в обычном — сами зовут `ipcRenderer.invoke(канал)`.
@@ -74,7 +120,41 @@ const SERVICES_CALLS = {
74
120
  themeSet: SERVICES_CHANNELS.THEME_SET,
75
121
  themeToggle: SERVICES_CHANNELS.THEME_TOGGLE,
76
122
  localeGet: SERVICES_CHANNELS.LOCALE_GET,
77
- localeSet: SERVICES_CHANNELS.LOCALE_SET
123
+ localeSet: SERVICES_CHANNELS.LOCALE_SET,
124
+ appPath: SERVICES_CHANNELS.APP_PATH,
125
+ appInfo: SERVICES_CHANNELS.APP_INFO,
126
+ appQuit: SERVICES_CHANNELS.APP_QUIT,
127
+ appRestart: SERVICES_CHANNELS.APP_RESTART,
128
+ dialogOpen: SERVICES_CHANNELS.DIALOG_OPEN,
129
+ dialogSave: SERVICES_CHANNELS.DIALOG_SAVE,
130
+ dialogMessage: SERVICES_CHANNELS.DIALOG_MESSAGE,
131
+ sessionCacheSize: SERVICES_CHANNELS.SESSION_CACHE_SIZE,
132
+ sessionClearCache: SERVICES_CHANNELS.SESSION_CLEAR_CACHE,
133
+ sessionClearStorage: SERVICES_CHANNELS.SESSION_CLEAR_STORAGE
134
+ };
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
78
158
  };
79
159
 
80
160
  /** Каналы моста: окно, тема и прикладные сообщения безопасного окна */
@@ -98,5 +178,7 @@ const BRIDGE_ARGS = {
98
178
 
99
179
  exports.SERVICES_CHANNELS = SERVICES_CHANNELS
100
180
  exports.SERVICES_CALLS = SERVICES_CALLS
181
+ exports.SERVICES_SYNC_CALLS = SERVICES_SYNC_CALLS
101
182
  exports.BRIDGE_CHANNELS = BRIDGE_CHANNELS
183
+ exports.APP_PATH_NAMES = APP_PATH_NAMES
102
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
  * Десктоп-сервисы со стороны страницы: буфер обмена, последние документы, прогресс на иконке
@@ -125,6 +125,107 @@ class Desktop {
125
125
  onChange: (listener = () => {}) => Desktop.#subscribeLocale(listener)
126
126
  };
127
127
 
128
+ /**
129
+ * Приложение: пути, сведения и сырые модули Electron.
130
+ *
131
+ * Странице модули main не отдают — это и есть рекомендация Electron вместо `remote`,
132
+ * поэтому пути и сведения идут по IPC. В main-процессе IPC нет, и те же методы отвечают
133
+ * значением: `path()` берёт путь у Electron, а `get()`/`getSession()`/`getWebContents()`
134
+ * отдают сами объекты.
135
+ *
136
+ * ```javascript
137
+ * const dir = Desktop.app.path('userData'); // путь приходит сразу, без промиса
138
+ * const info = await Desktop.app.info(); // {name, version, locale, isPackaged}
139
+ * Desktop.app.quit(); // закрыть приложение — тоже без промиса
140
+ * ```
141
+ */
142
+ static app = {
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)),
159
+ /** @returns {Promise<{name: string, version: string, locale: string, systemLocale: string, isPackaged: boolean}>} */
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")
198
+ };
199
+
200
+ /**
201
+ * Системные диалоги — в том окне, которое их вызвало: родитель и модальность main
202
+ * берёт у отправителя. Результат открытия — `{canceled, filePaths}`, как у `dialog.*`.
203
+ *
204
+ * ```javascript
205
+ * const {canceled, filePaths} = await Desktop.dialog.open({properties: ["openFile"]});
206
+ * ```
207
+ */
208
+ static dialog = {
209
+ open: (options = Object()) => Desktop.#call("dialogOpen", options),
210
+ save: (options = Object()) => Desktop.#call("dialogSave", options),
211
+ message: (options = Object()) => Desktop.#call("dialogMessage", options)
212
+ };
213
+
214
+ /**
215
+ * Сессия окна-отправителя: кеш и хранилище. Именно своя сессия, а не чужого окна.
216
+ *
217
+ * ```javascript
218
+ * await Desktop.session.clearCache();
219
+ * await Desktop.session.clearStorageData({storages: ["cookies"]});
220
+ * ```
221
+ */
222
+ static session = {
223
+ /** @returns {Promise<number>} размер кеша в байтах */
224
+ getCacheSize: () => Desktop.#call("sessionCacheSize"),
225
+ clearCache: () => Desktop.#call("sessionClearCache"),
226
+ clearStorageData: (options = Object()) => Desktop.#call("sessionClearStorage", options)
227
+ };
228
+
128
229
  // accelerator → набор обработчиков этого окна
129
230
  static #shortcut_handlers = new Map();
130
231
  static #shortcut_event_bound = false;
@@ -138,6 +239,9 @@ class Desktop {
138
239
  static #history_handlers = new Set();
139
240
  static #history_event_bound = false;
140
241
  static #history_bridge_unsubscribe = undefined;
242
+ // Пути приложения, уже полученные значением: `sendSync` останавливает страницу на время
243
+ // ответа, поэтому за одним и тем же именем в main ходят один раз
244
+ static #app_paths = new Map();
141
245
 
142
246
  static #bridge() {
143
247
  if (typeof window === "undefined" || window.chui === undefined) return undefined;
@@ -152,6 +256,65 @@ class Desktop {
152
256
  return ipcRenderer.invoke(SERVICES_CALLS[method], ...args);
153
257
  }
154
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
+
155
318
  static #subscribe(accelerator = String(), handler = () => {}) {
156
319
  const handlers = Desktop.#shortcut_handlers.get(accelerator) ?? new Set();
157
320
  const first = handlers.size === 0;
package/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // === ИНСТРУМЕНТЫ ===
2
- const {app, BrowserWindow, Menu, Tray, ipcMain, ipcRenderer, shell, nativeTheme, session, webContents, systemPreferences} = require('electron');
3
- const { dialog } = require('@electron/remote/main');
2
+ const {app, BrowserWindow, Menu, Tray, ipcMain, ipcRenderer, shell, nativeTheme, session, webContents, systemPreferences, dialog} = require('electron');
4
3
  // Одно хранилище настроек на модуль: его получает приложение через `require('chuijs').store`,
5
4
  // из него читают тему, язык и история буфера, а `Settings` берёт его для своих значений по умолчанию
6
5
  const {store} = require("./framework/modules/chui_store/chui_store");
@@ -50,6 +49,7 @@ const {Descriptions} = require('./framework/components/chui_descriptions/descrip
50
49
  const {Statistic} = require('./framework/components/chui_statistic/statistic');
51
50
  const {Backtop} = require('./framework/components/chui_backtop/backtop');
52
51
  const {Affix} = require('./framework/components/chui_affix/affix');
52
+ const {StickyBar} = require('./framework/components/chui_sticky_bar/sticky_bar');
53
53
  const {Watermark} = require('./framework/components/chui_watermark/watermark');
54
54
  const {ShortcutHelp} = require('./framework/components/chui_shortcut_help/shortcut_help');
55
55
  const {Tooltip} = require('./framework/components/chui_tooltip/tooltip');
@@ -78,7 +78,7 @@ const {CodeBlock} = require('./framework/components/chui_code/code')
78
78
  const {HtmlBlock} = require('./framework/components/chui_html_block/html_block')
79
79
  const {sleep, render, getDefaultIcon, formatBytes} = require('./framework/modules/chui_functions');
80
80
  const {Shortcuts, Clipboard, RecentDocuments, TaskbarProgress} = require('./framework/modules/chui_services/chui_services');
81
- const {SERVICES_CHANNELS, BRIDGE_CHANNELS, BRIDGE_ARGS} = require('./framework/modules/chui_services/chui_channels');
81
+ const {SERVICES_CHANNELS, BRIDGE_CHANNELS, BRIDGE_ARGS, APP_PATH_NAMES} = require('./framework/modules/chui_services/chui_channels');
82
82
  const {Desktop} = require('./framework/modules/chui_services/desktop');
83
83
  const {I18n} = require('./framework/modules/chui_i18n/chui_i18n');
84
84
  const {Shell, Theme, Locale, ClipboardHistory: ClipboardHistoryService, Print} = require('./framework/modules/chui_services/chui_services');
@@ -150,6 +150,23 @@ const {QRCode} = require("./framework/components/chui_qrcode/qrcode");
150
150
  const {Log} = require("./framework/modules/chui_logger/chui_logger");
151
151
  const url = require("node:url");
152
152
 
153
+ /** Код исполняется на странице (renderer), а не в main: от этого зависит доступ к модулям Electron */
154
+ function inRenderer() {
155
+ return process !== undefined && process.type === "renderer";
156
+ }
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
+
153
170
  //VARS
154
171
  let isQuiting = false;
155
172
 
@@ -228,6 +245,9 @@ class Main {
228
245
  // Десктоп-сервисы (буфер, недавние, прогресс, глобальные клавиши) и их подписки по окнам
229
246
  #services_enabled = false;
230
247
  #service_handlers = [];
248
+ // Синхронные каналы снимаются не `removeHandler`, а `removeListener`: они зарегистрированы
249
+ // через `ipcMain.on` — см. `#handleSync`
250
+ #service_sync_handlers = [];
231
251
  #shortcut_subscribers = new Map();
232
252
  #shortcut_watchers = new Map();
233
253
  #progress_windows = new Map();
@@ -236,6 +256,7 @@ class Main {
236
256
  #security = {contextIsolation: false, csp: false, channels: []};
237
257
  #secure_contents = new Set();
238
258
  #bridge_enabled = false;
259
+ #window_actions_bound = false;
239
260
  #theme_bound = false;
240
261
  #page_message_handlers = new Map();
241
262
  #onUncaughtException = (error) => {
@@ -431,7 +452,6 @@ class Main {
431
452
  plugins: false,
432
453
  nodeIntegration: false,
433
454
  contextIsolation: true,
434
- enableRemoteModule: false,
435
455
  sandbox: false,
436
456
  preload: Main.SECURE_PRELOAD,
437
457
  additionalArguments: Main.#bridgeArguments(options.render, security),
@@ -448,7 +468,6 @@ class Main {
448
468
  preload: options.render === undefined ? this.#renderer : options.render,
449
469
  spellcheck: false,
450
470
  webviewTag: true,
451
- enableRemoteModule: true,
452
471
  webSecurity: this.#webSecurity,
453
472
  nodeIntegrationInSubFrames: true,
454
473
  },
@@ -627,6 +646,9 @@ class Main {
627
646
  enableServices() {
628
647
  if (this.#services_enabled) return this;
629
648
  this.#services_enabled = true;
649
+ // Кнопки окна: каналы нужны и обычным окнам — в них `AppLayout` зовёт их по IPC,
650
+ // потому что `@electron/remote` в рендерере больше нет
651
+ this.#enableWindowActions();
630
652
  this.services = {
631
653
  shortcuts: new Shortcuts(),
632
654
  shell: new Shell(),
@@ -686,6 +708,51 @@ class Main {
686
708
  this.services.clipboard.clear();
687
709
  return true;
688
710
  });
711
+ // Приложение и системные диалоги для страницы: Electron советует IPC вместо `remote`.
712
+ // Путь — только из белого списка, диалог и сессия — у окна-отправителя.
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));
718
+ this.#handle(SERVICES_CHANNELS.APP_INFO, () => ({
719
+ name: app.getName(),
720
+ version: app.getVersion(),
721
+ locale: app.getLocale(),
722
+ systemLocale: app.getSystemLocale(),
723
+ isPackaged: app.isPackaged
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
+ });
750
+ this.#handle(SERVICES_CHANNELS.DIALOG_OPEN, (event, options) => Main.#showDialog(event, "showOpenDialog", options));
751
+ this.#handle(SERVICES_CHANNELS.DIALOG_SAVE, (event, options) => Main.#showDialog(event, "showSaveDialog", options));
752
+ this.#handle(SERVICES_CHANNELS.DIALOG_MESSAGE, (event, options) => Main.#showDialog(event, "showMessageBox", options));
753
+ this.#handle(SERVICES_CHANNELS.SESSION_CACHE_SIZE, (event) => event.sender.session.getCacheSize());
754
+ this.#handle(SERVICES_CHANNELS.SESSION_CLEAR_CACHE, (event) => event.sender.session.clearCache());
755
+ this.#handle(SERVICES_CHANNELS.SESSION_CLEAR_STORAGE, (event, options) => event.sender.session.clearStorageData(options));
689
756
  this.#handle(SERVICES_CHANNELS.RECENT_ADD, (event, path, name) => {
690
757
  this.services.recent.add(path, name);
691
758
  return true;
@@ -741,13 +808,42 @@ class Main {
741
808
  return this;
742
809
  }
743
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
+
744
830
  /**
745
831
  * Включает мост безопасных окон (`contextBridge`): управление окном, тема и прикладные
746
832
  * сообщения по белому списку каналов. Вызывается сам при создании безопасного окна.
747
833
  */
748
- enableBridge() {
749
- if (this.#bridge_enabled) return this;
750
- this.#bridge_enabled = true;
834
+ /** Диалог в окне отправителя: без окна (уже закрыто) Electron зовётся без родителя */
835
+ static #showDialog(event, method = String(), options = Object()) {
836
+ const window = BrowserWindow.fromWebContents(event.sender);
837
+ return window === null ? dialog[method](options) : dialog[method](window, options);
838
+ }
839
+
840
+ /**
841
+ * Каналы кнопок окна. Ими пользуются оба режима: безопасное окно — через мост
842
+ * `window.chui.window`, обычное — прямым `ipcRenderer.invoke`. Регистрируются один раз на приложение.
843
+ */
844
+ #enableWindowActions() {
845
+ if (this.#window_actions_bound) return this;
846
+ this.#window_actions_bound = true;
751
847
  this.#handle(BRIDGE_CHANNELS.WINDOW_ACTION, (event, action) => Main.#applyWindowAction(event.sender, action));
752
848
  this.#handle(BRIDGE_CHANNELS.WINDOW_STATE, (event) => {
753
849
  const window = BrowserWindow.fromWebContents(event.sender);
@@ -759,6 +855,13 @@ class Main {
759
855
  isFocused: window.isFocused()
760
856
  };
761
857
  });
858
+ return this;
859
+ }
860
+
861
+ enableBridge() {
862
+ if (this.#bridge_enabled) return this;
863
+ this.#bridge_enabled = true;
864
+ this.#enableWindowActions();
762
865
  this.#handle(BRIDGE_CHANNELS.THEME_GET, () => (nativeTheme.shouldUseDarkColors ? "dark" : "light"));
763
866
  this.#handle(BRIDGE_CHANNELS.LOCALE_GET, () => this.services.locale.get());
764
867
  this.#handle(BRIDGE_CHANNELS.MESSAGE, (event, channel, ...args) => {
@@ -790,7 +893,7 @@ class Main {
790
893
  return this.#send(BRIDGE_CHANNELS.MESSAGE, [channel, payload], windowName);
791
894
  }
792
895
 
793
- /** Действия кнопок окна для безопасного режима (там нет @electron/remote) */
896
+ /** Действия кнопок окна: и обычные окна, и безопасные зовут их по IPC */
794
897
  static #applyWindowAction(sender, action = String()) {
795
898
  const window = BrowserWindow.fromWebContents(sender);
796
899
  if (window === null || window.isDestroyed()) return false;
@@ -1008,6 +1111,8 @@ class Main {
1008
1111
  // Десктоп-сервисы: снимаем обработчики IPC, подписки клавиш и сами клавиши
1009
1112
  for (const [channel] of this.#service_handlers) ipcMain.removeHandler(channel);
1010
1113
  this.#service_handlers = [];
1114
+ for (const [channel, listener] of this.#service_sync_handlers) ipcMain.removeListener(channel, listener);
1115
+ this.#service_sync_handlers = [];
1011
1116
  this.#shortcut_subscribers.clear();
1012
1117
  this.#shortcut_watchers.clear();
1013
1118
  this.#progress_windows.clear();
@@ -1021,6 +1126,7 @@ class Main {
1021
1126
  this.#on_theme_updated = undefined;
1022
1127
  this.#page_message_handlers.clear();
1023
1128
  this.#bridge_enabled = false;
1129
+ this.#window_actions_bound = false;
1024
1130
  if (this.#updaterListening) {
1025
1131
  this.#updaterListening = false;
1026
1132
  require("electron-updater").autoUpdater.removeListener('update-downloaded', this.#onUpdateDownloaded);
@@ -1074,9 +1180,7 @@ class Main {
1074
1180
  Menu.setApplicationMenu(null)
1075
1181
  }
1076
1182
  this.enableServices();
1077
- const window = this.#buildWindow({name: Main.MAIN_WINDOW, hideOnClose: options.hideOnClose});
1078
- require("@electron/remote/main").initialize();
1079
- require("@electron/remote/main").enable(window.webContents);
1183
+ this.#buildWindow({name: Main.MAIN_WINDOW, hideOnClose: options.hideOnClose});
1080
1184
  })
1081
1185
  }
1082
1186
 
@@ -1168,53 +1272,6 @@ class Styles {
1168
1272
  };
1169
1273
  }
1170
1274
 
1171
- class Application {
1172
- getApp() {
1173
- if (process && process.type === 'renderer') {
1174
- return require("@electron/remote").app;
1175
- } else {
1176
- return app;
1177
- }
1178
- }
1179
- getSession() {
1180
- if (process && process.type === 'renderer') {
1181
- return require("@electron/remote").session;
1182
- } else {
1183
- return session;
1184
- }
1185
- }
1186
- getWebContents() {
1187
- if (process && process.type === 'renderer') {
1188
- return require("@electron/remote").webContents;
1189
- } else {
1190
- return webContents;
1191
- }
1192
- }
1193
- }
1194
-
1195
- class App {
1196
- static get() { return new Application().getApp() }
1197
- static getSession() { return new Application().getSession() }
1198
- static getWebContents() { return new Application().getWebContents() }
1199
- // ПУТИ
1200
- static homePath() { return new Application().getApp().getPath("home") }
1201
- static appDataPath() { return new Application().getApp().getPath("appData") }
1202
- static userDataPath() { return new Application().getApp().getPath("userData") }
1203
- static sessionDataPath() { return new Application().getApp().getPath("sessionData") }
1204
- static logsPath() { return new Application().getApp().getPath("logs") }
1205
- static tempPath() { return new Application().getApp().getPath("temp") }
1206
- static exePath() { return new Application().getApp().getPath("exe") }
1207
- static modulePath() { return new Application().getApp().getPath("module") }
1208
- static desktopPath() { return new Application().getApp().getPath("desktop") }
1209
- static documentsPath() { return new Application().getApp().getPath("documents") }
1210
- static downloadsPath() { return new Application().getApp().getPath("downloads") }
1211
- static musicPath() { return new Application().getApp().getPath("music") }
1212
- static picturesPath() { return new Application().getApp().getPath("pictures") }
1213
- static videosPath() { return new Application().getApp().getPath("videos") }
1214
- static recentPath() { return new Application().getApp().getPath("recent") }
1215
- static crashDumpsPath() { return new Application().getApp().getPath("crashDumps") }
1216
- }
1217
-
1218
1275
  module.exports = {
1219
1276
  Main: Main,
1220
1277
  sleep: sleep,
@@ -1246,6 +1303,7 @@ module.exports = {
1246
1303
  Statistic: Statistic,
1247
1304
  Backtop: Backtop,
1248
1305
  Affix: Affix,
1306
+ StickyBar: StickyBar,
1249
1307
  Watermark: Watermark,
1250
1308
  ShortcutHelp: ShortcutHelp,
1251
1309
  Alert: Alert,
@@ -1371,8 +1429,6 @@ module.exports = {
1371
1429
  transliterate: transliterate,
1372
1430
  store: store,
1373
1431
  //
1374
- App: App,
1375
- //
1376
1432
  // Десктоп-сервисы: классы main-процесса и доступ из страницы
1377
1433
  Shortcuts: Shortcuts,
1378
1434
  Shell: Shell,
@@ -1388,7 +1444,12 @@ module.exports = {
1388
1444
  //
1389
1445
  downloadSession: downloadSession,
1390
1446
  Log: Log,
1391
- chooseFile: dialog,
1447
+ chooseFile: {
1448
+ // Диалоги: странице — по IPC (окно берётся у отправителя), в main — обычный dialog Electron
1449
+ showOpenDialog: (options = {}) => (inRenderer() ? Desktop.dialog.open(options) : dialog.showOpenDialog(options)),
1450
+ showSaveDialog: (options = {}) => (inRenderer() ? Desktop.dialog.save(options) : dialog.showSaveDialog(options)),
1451
+ showMessageBox: (options = {}) => (inRenderer() ? Desktop.dialog.message(options) : dialog.showMessageBox(options))
1452
+ },
1392
1453
  //
1393
1454
  systemPreferences: systemPreferences
1394
1455
  }