chuijs 4.0.2 → 4.0.4
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 +597 -0
- package/README.md +43 -644
- package/framework/appLayout/default/chui_app_layout.js +602 -496
- package/framework/appLayout/default/global_style.css +288 -97
- package/framework/appLayout/default/main_theme_style.css +188 -101
- package/framework/components/chui_accordion/accordion.js +38 -35
- package/framework/components/chui_accordion/styles.css +13 -12
- package/framework/components/chui_action_sheet/action_sheet.js +93 -64
- package/framework/components/chui_action_sheet/styles.css +12 -5
- package/framework/components/chui_actions_bar/actions_bar.js +338 -0
- package/framework/components/chui_actions_bar/styles.css +139 -0
- package/framework/components/chui_affix/affix.js +16 -11
- package/framework/components/chui_affix/styles.css +5 -0
- package/framework/components/chui_alert/alert.js +34 -19
- package/framework/components/chui_alert/styles.css +7 -0
- package/framework/components/chui_avatar/avatar.js +36 -35
- package/framework/components/chui_avatar/styles.css +1 -1
- package/framework/components/chui_avatar_group/avatar_group.js +25 -23
- package/framework/components/chui_avatar_group/styles.css +1 -1
- package/framework/components/chui_backtop/backtop.js +60 -21
- package/framework/components/chui_backtop/styles.css +21 -0
- package/framework/components/chui_badge/badge.js +30 -28
- package/framework/components/chui_badge/styles.css +6 -6
- package/framework/components/chui_banner/banner.js +222 -0
- package/framework/components/chui_banner/styles.css +170 -0
- package/framework/components/chui_breadcrumb/breadcrumb.js +15 -13
- package/framework/components/chui_breadcrumb/styles.css +1 -1
- package/framework/components/chui_button/button.js +52 -36
- package/framework/components/chui_button/styles.css +7 -7
- package/framework/components/chui_calendar/calendar.js +18 -16
- package/framework/components/chui_card/card.js +52 -32
- package/framework/components/chui_card_scroll/card_scroll.js +169 -0
- package/framework/components/chui_card_scroll/styles.css +86 -0
- package/framework/components/chui_chat/chat.js +140 -79
- package/framework/components/chui_chat/styles.css +18 -7
- package/framework/components/chui_clipboard_history/clipboard_history.js +53 -39
- package/framework/components/chui_clipboard_history/styles.css +13 -9
- package/framework/components/chui_code/code.js +10 -10
- package/framework/components/chui_code/styles.css +4 -4
- package/framework/components/chui_code_editor/code_editor.js +73 -57
- package/framework/components/chui_code_editor/styles.css +6 -5
- package/framework/components/chui_command_palette/command_palette.js +87 -64
- package/framework/components/chui_command_palette/styles.css +5 -5
- package/framework/components/chui_console/console.js +13 -13
- package/framework/components/chui_console/styles.css +6 -3
- package/framework/components/chui_content_block/content_block.js +35 -21
- package/framework/components/chui_content_block/styles.css +2 -2
- package/framework/components/chui_context_menu/context_menu.js +171 -58
- package/framework/components/chui_context_menu/styles.css +82 -8
- package/framework/components/chui_custom_element/custom_element.js +25 -18
- package/framework/components/chui_data_grid/data_grid.js +633 -471
- package/framework/components/chui_data_grid/styles.css +24 -22
- package/framework/components/chui_descriptions/descriptions.js +34 -17
- package/framework/components/chui_descriptions/styles.css +1 -1
- package/framework/components/chui_details/details.js +42 -36
- package/framework/components/chui_details/styles.css +12 -11
- package/framework/components/chui_diff_viewer/diff_viewer.js +172 -147
- package/framework/components/chui_diff_viewer/styles.css +10 -10
- package/framework/components/chui_divider/divider.js +19 -17
- package/framework/components/chui_divider/styles.css +2 -2
- package/framework/components/chui_drawer/drawer.js +63 -38
- package/framework/components/chui_drawer/styles.css +18 -4
- package/framework/components/chui_drop_zone/drop_zone.js +37 -27
- package/framework/components/chui_drop_zone/styles.css +10 -6
- package/framework/components/chui_empty_state/empty_state.js +20 -15
- package/framework/components/chui_fieldset/fieldset.js +40 -23
- package/framework/components/chui_fieldset/styles.css +6 -6
- package/framework/components/chui_file_explorer/file_explorer.js +260 -189
- package/framework/components/chui_file_explorer/styles.css +8 -7
- package/framework/components/chui_form/form.js +40 -28
- package/framework/components/chui_form/styles.css +1 -1
- package/framework/components/chui_gantt/gantt.js +102 -78
- package/framework/components/chui_gantt/styles.css +5 -5
- package/framework/components/chui_graphs/graphs.js +376 -239
- package/framework/components/chui_graphs/styles.css +27 -6
- package/framework/components/chui_hero/hero.js +98 -37
- package/framework/components/chui_hero/styles.css +176 -7
- package/framework/components/chui_html_block/html_block.js +8 -8
- package/framework/components/chui_html_block/styles.css +3 -3
- package/framework/components/chui_hx/hx.js +3 -3
- package/framework/components/chui_hx/styles.css +13 -6
- package/framework/components/chui_icons/icons.js +2115 -2115
- package/framework/components/chui_icons/styles.css +4 -3
- package/framework/components/chui_inputs/chui_check_box/check_box.js +31 -19
- package/framework/components/chui_inputs/chui_check_box/styles.css +30 -21
- package/framework/components/chui_inputs/chui_chips/chips.js +141 -70
- package/framework/components/chui_inputs/chui_chips/styles.css +32 -7
- package/framework/components/chui_inputs/chui_color_picker/color_picker.js +55 -38
- package/framework/components/chui_inputs/chui_color_picker/styles.css +7 -7
- package/framework/components/chui_inputs/chui_combo_box/combo_box.js +127 -109
- package/framework/components/chui_inputs/chui_combo_box/styles.css +23 -21
- package/framework/components/chui_inputs/chui_date/date.js +161 -127
- package/framework/components/chui_inputs/chui_date/styles.css +20 -19
- package/framework/components/chui_inputs/chui_date_range/date_range.js +61 -50
- package/framework/components/chui_inputs/chui_date_range/styles.css +3 -3
- package/framework/components/chui_inputs/chui_datetime/datetime.js +47 -39
- package/framework/components/chui_inputs/chui_datetime/styles.css +3 -3
- package/framework/components/chui_inputs/chui_email/email.js +48 -36
- package/framework/components/chui_inputs/chui_email/styles.css +11 -11
- package/framework/components/chui_inputs/chui_file/file.js +71 -44
- package/framework/components/chui_inputs/chui_file/styles.css +23 -19
- package/framework/components/chui_inputs/chui_hotkey/hotkey.js +101 -76
- package/framework/components/chui_inputs/chui_hotkey/styles.css +6 -6
- package/framework/components/chui_inputs/chui_masked/masked.js +83 -72
- package/framework/components/chui_inputs/chui_masked/styles.css +5 -5
- package/framework/components/chui_inputs/chui_mention/mention.js +87 -40
- package/framework/components/chui_inputs/chui_mention/styles.css +14 -8
- package/framework/components/chui_inputs/chui_multi_combo_box/multi_combo_box.js +242 -212
- package/framework/components/chui_inputs/chui_multi_combo_box/styles.css +23 -15
- package/framework/components/chui_inputs/chui_number/number.js +84 -70
- package/framework/components/chui_inputs/chui_number/styles.css +13 -13
- package/framework/components/chui_inputs/chui_otp/otp.js +71 -62
- package/framework/components/chui_inputs/chui_otp/styles.css +5 -5
- package/framework/components/chui_inputs/chui_password/password.js +72 -56
- package/framework/components/chui_inputs/chui_password/styles.css +15 -15
- package/framework/components/chui_inputs/chui_radio_button/radio_button.js +37 -25
- package/framework/components/chui_inputs/chui_radio_button/styles.css +22 -22
- package/framework/components/chui_inputs/chui_rating/rating.js +54 -50
- package/framework/components/chui_inputs/chui_rating/styles.css +3 -3
- package/framework/components/chui_inputs/chui_select_box/select_box.js +132 -108
- package/framework/components/chui_inputs/chui_select_box/styles.css +21 -21
- package/framework/components/chui_inputs/chui_slider/slider.js +41 -38
- package/framework/components/chui_inputs/chui_slider/styles.css +7 -5
- package/framework/components/chui_inputs/chui_text/styles.css +14 -14
- package/framework/components/chui_inputs/chui_text/text.js +69 -52
- package/framework/components/chui_inputs/chui_text_area/styles.css +14 -14
- package/framework/components/chui_inputs/chui_text_area/text_area.js +59 -36
- package/framework/components/chui_inputs/chui_time/styles.css +18 -20
- package/framework/components/chui_inputs/chui_time/time.js +71 -49
- package/framework/components/chui_inputs/chui_toggle/styles.css +13 -6
- package/framework/components/chui_inputs/chui_toggle/toggle.js +27 -27
- package/framework/components/chui_json_editor/json_editor.js +137 -127
- package/framework/components/chui_json_editor/styles.css +5 -5
- package/framework/components/chui_json_viewer/json_viewer.js +133 -114
- package/framework/components/chui_json_viewer/styles.css +9 -8
- package/framework/components/chui_kanban/kanban.js +78 -60
- package/framework/components/chui_kanban/styles.css +8 -4
- package/framework/components/chui_kbd/kbd.js +12 -10
- package/framework/components/chui_kbd/styles.css +1 -1
- package/framework/components/chui_label/label.js +31 -29
- package/framework/components/chui_label/styles.css +3 -3
- package/framework/components/chui_link/link.js +36 -29
- package/framework/components/chui_link/styles.css +11 -4
- package/framework/components/chui_list/list.js +42 -38
- package/framework/components/chui_list/styles.css +4 -3
- package/framework/components/chui_markdown_editor/markdown_editor.js +125 -109
- package/framework/components/chui_markdown_editor/styles.css +5 -5
- package/framework/components/chui_media/audio.js +457 -364
- package/framework/components/chui_media/audio_styles.css +74 -37
- package/framework/components/chui_media/image.js +99 -64
- package/framework/components/chui_media/image_compare.js +86 -55
- package/framework/components/chui_media/image_compare_styles.css +5 -5
- package/framework/components/chui_media/image_styles.css +4 -4
- package/framework/components/chui_media/video.js +264 -171
- package/framework/components/chui_media/video_styles.css +57 -24
- package/framework/components/chui_menu_bar/menu_bar.js +67 -65
- package/framework/components/chui_menu_bar/styles.css +14 -12
- package/framework/components/chui_message_bar/message_bar.js +551 -0
- package/framework/components/chui_message_bar/styles.css +248 -0
- package/framework/components/chui_modal/modal.js +81 -56
- package/framework/components/chui_modal/styles.css +3 -2
- package/framework/components/chui_notification/notification.js +107 -69
- package/framework/components/chui_notification/notification_download.js +28 -77
- package/framework/components/chui_notification/notification_download_card.js +115 -0
- package/framework/components/chui_notification/notification_download_progress.css +5 -4
- package/framework/components/chui_notification/notification_download_progress.js +73 -105
- package/framework/components/chui_notification/notification_update.js +44 -37
- package/framework/components/chui_notification/status_plate.css +3 -3
- package/framework/components/chui_notification/status_plate.js +2 -2
- package/framework/components/chui_notification/styles.css +6 -1
- package/framework/components/chui_notification/styles_download.css +2 -2
- package/framework/components/chui_notification/styles_update.css +3 -3
- package/framework/components/chui_org_chart/org_chart.js +101 -62
- package/framework/components/chui_org_chart/styles.css +2 -2
- package/framework/components/chui_pagination/pagination.js +65 -30
- package/framework/components/chui_pagination/styles.css +1 -1
- package/framework/components/chui_paragraph/paragraph.js +2 -2
- package/framework/components/chui_paragraph/styles.css +3 -3
- package/framework/components/chui_popover/popover.js +77 -50
- package/framework/components/chui_popover/styles.css +1 -1
- package/framework/components/chui_popups/popups.js +210 -181
- package/framework/components/chui_popups/styles.css +7 -7
- package/framework/components/chui_print_button/print_button.js +48 -33
- package/framework/components/chui_progress_bar/progress_bar.js +49 -19
- package/framework/components/chui_progress_bar/styles.css +6 -6
- package/framework/components/chui_progress_ring/progress_ring.js +32 -30
- package/framework/components/chui_progress_ring/styles.css +1 -1
- package/framework/components/chui_qrcode/qrcode.js +85 -41
- package/framework/components/chui_query_builder/query_builder.js +263 -170
- package/framework/components/chui_query_builder/styles.css +4 -4
- package/framework/components/chui_radio_group/radio_group.js +36 -26
- package/framework/components/chui_radio_group/styles.css +2 -2
- package/framework/components/chui_result/result.js +41 -31
- package/framework/components/chui_result/styles.css +3 -3
- package/framework/components/chui_scheduler/scheduler.js +126 -104
- package/framework/components/chui_scheduler/styles.css +11 -7
- package/framework/components/chui_screen_spinner/screen_spinner.js +167 -0
- package/framework/components/chui_screen_spinner/styles.css +49 -0
- package/framework/components/chui_segmented_control/segmented_control.js +90 -27
- package/framework/components/chui_segmented_control/styles.css +32 -6
- package/framework/components/chui_settings/settings.js +160 -87
- package/framework/components/chui_settings/styles.css +4 -4
- package/framework/components/chui_shortcut_help/shortcut_help.js +82 -61
- package/framework/components/chui_sidebar_nav/sidebar_nav.js +132 -53
- package/framework/components/chui_sidebar_nav/styles.css +9 -3
- package/framework/components/chui_skeleton/skeleton.js +29 -24
- package/framework/components/chui_slideshow/slideshow.js +122 -103
- package/framework/components/chui_slideshow/styles.css +22 -16
- package/framework/components/chui_spinner/spinner.js +17 -17
- package/framework/components/chui_spinner/styles.css +0 -1
- package/framework/components/chui_split_button/split_button.js +53 -30
- package/framework/components/chui_split_button/styles.css +22 -6
- package/framework/components/chui_splitter/splitter.js +46 -39
- package/framework/components/chui_splitter/styles.css +2 -2
- package/framework/components/chui_statistic/statistic.js +111 -84
- package/framework/components/chui_status_bar/status_bar.js +11 -9
- package/framework/components/chui_status_bar/styles.css +2 -2
- package/framework/components/chui_stepper/stepper.js +230 -54
- package/framework/components/chui_stepper/styles.css +79 -14
- package/framework/components/chui_sticky_bar/sticky_bar.js +53 -17
- package/framework/components/chui_sticky_bar/styles.css +11 -7
- package/framework/components/chui_tabs/chui_tabs.js +100 -46
- package/framework/components/chui_tabs/styles.css +43 -15
- package/framework/components/chui_text_editor/styles.css +24 -23
- package/framework/components/chui_text_editor/text_editor.js +103 -79
- package/framework/components/chui_text_editor/text_editor_panel.js +428 -367
- package/framework/components/chui_theme_toggle/theme_toggle.js +46 -25
- package/framework/components/chui_timeline/styles.css +3 -1
- package/framework/components/chui_timeline/timeline.js +14 -12
- package/framework/components/chui_toast_queue/styles.css +11 -11
- package/framework/components/chui_toast_queue/toast_queue.js +82 -73
- package/framework/components/chui_toolbar/styles.css +2 -2
- package/framework/components/chui_toolbar/toolbar.js +20 -13
- package/framework/components/chui_tooltip/styles.css +2 -2
- package/framework/components/chui_tooltip/tooltip.js +40 -27
- package/framework/components/chui_tour/styles.css +12 -7
- package/framework/components/chui_tour/tour.js +132 -98
- package/framework/components/chui_transfer/styles.css +8 -7
- package/framework/components/chui_transfer/transfer.js +143 -123
- package/framework/components/chui_tree_table/styles.css +4 -4
- package/framework/components/chui_tree_table/tree_table.js +88 -74
- package/framework/components/chui_tree_view/styles.css +14 -10
- package/framework/components/chui_tree_view/tree_view.js +298 -253
- package/framework/components/chui_user_bar/styles.css +145 -0
- package/framework/components/chui_user_bar/user_bar.js +402 -0
- package/framework/components/chui_user_list_bar/styles.css +173 -0
- package/framework/components/chui_user_list_bar/user_list_bar.js +485 -0
- package/framework/components/chui_watermark/watermark.js +41 -33
- package/framework/components/chui_webview/styles.css +2 -2
- package/framework/components/chui_webview/webview.js +64 -52
- package/framework/components/chui_write_bar/styles.css +172 -0
- package/framework/components/chui_write_bar/write_bar.js +320 -0
- package/framework/main_window.js +1234 -0
- package/framework/modules/chui_a11y/chui_a11y.js +153 -123
- package/framework/modules/chui_animations/animations.js +105 -43
- package/framework/modules/chui_animations/motion.js +120 -0
- package/framework/modules/chui_animations/styles.css +37 -4
- package/framework/modules/chui_bridge/preload.js +36 -25
- package/framework/modules/chui_functions.js +69 -63
- package/framework/modules/chui_highlight/chui_highlight.js +25 -22
- package/framework/modules/chui_highlight/styles.css +13 -10
- package/framework/modules/chui_i18n/chui_i18n.js +12 -10
- package/framework/modules/chui_i18n/locales/a11y.js +5 -5
- package/framework/modules/chui_i18n/locales/app_layout.js +5 -5
- package/framework/modules/chui_i18n/locales/calendar.js +3 -3
- package/framework/modules/chui_i18n/locales/common.js +3 -3
- package/framework/modules/chui_i18n/locales/data.js +36 -26
- package/framework/modules/chui_i18n/locales/editors.js +40 -13
- package/framework/modules/chui_i18n/locales/feedback.js +65 -3
- package/framework/modules/chui_i18n/locales/forms.js +8 -8
- package/framework/modules/chui_i18n/locales/graphics.js +8 -8
- package/framework/modules/chui_i18n/locales/media.js +4 -4
- package/framework/modules/chui_i18n/locales/navigation.js +12 -8
- package/framework/modules/chui_i18n/locales/validate.js +3 -3
- package/framework/modules/chui_json/chui_json.js +11 -11
- package/framework/modules/chui_layout/chui_layout.js +12 -10
- package/framework/modules/chui_logger/chui_logger.js +47 -28
- package/framework/modules/chui_page/page.js +39 -33
- package/framework/modules/chui_palette/palette.js +61 -0
- package/framework/modules/chui_qr/chui_qr.js +179 -88
- package/framework/modules/chui_scrollbar/chui_scrollbar.js +79 -68
- package/framework/modules/chui_scrollbar/styles.css +4 -2
- package/framework/modules/chui_services/chui_channels.js +27 -13
- package/framework/modules/chui_services/chui_services.js +47 -39
- package/framework/modules/chui_services/desktop.js +108 -101
- package/framework/modules/chui_sortable/chui_sortable.js +33 -28
- package/framework/modules/chui_validate/chui_validate.js +10 -7
- package/framework/tray_bar/chui_menu_items.js +136 -46
- package/index.d.ts +5046 -0
- package/index.js +199 -1254
- package/package.json +81 -68
- package/framework/components/telegram_bot/chui_telegram_bot.js +0 -184
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Окно приложения: класс `Main` — создание окон, реестр, IPC-обработчики сервисов,
|
|
3
|
+
* глобальные клавиши и обновление.
|
|
4
|
+
*
|
|
5
|
+
* Вынесен из `index.js` отдельным модулем: точка сборки публичного API перестала быть
|
|
6
|
+
* самым большим файлом репозитория, а окно читается само по себе. Публичный доступ
|
|
7
|
+
* к классу остался прежним — `require("chuijs").Main`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
app,
|
|
12
|
+
BrowserWindow,
|
|
13
|
+
dialog,
|
|
14
|
+
ipcMain,
|
|
15
|
+
Menu,
|
|
16
|
+
nativeTheme,
|
|
17
|
+
session,
|
|
18
|
+
shell,
|
|
19
|
+
Tray,
|
|
20
|
+
webContents,
|
|
21
|
+
} = require("electron");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const url = require("node:url");
|
|
25
|
+
const { store } = require("./modules/chui_store/chui_store");
|
|
26
|
+
const { getDefaultIcon } = require("./modules/chui_functions");
|
|
27
|
+
const {
|
|
28
|
+
BRIDGE_ARGS,
|
|
29
|
+
BRIDGE_CHANNELS,
|
|
30
|
+
SERVICES_CHANNELS,
|
|
31
|
+
APP_PATH_NAMES,
|
|
32
|
+
} = require("./modules/chui_services/chui_channels");
|
|
33
|
+
const {
|
|
34
|
+
Clipboard,
|
|
35
|
+
ClipboardHistory: ClipboardHistoryService,
|
|
36
|
+
Locale,
|
|
37
|
+
Print,
|
|
38
|
+
RecentDocuments,
|
|
39
|
+
Shell,
|
|
40
|
+
Shortcuts,
|
|
41
|
+
TaskbarProgress,
|
|
42
|
+
Theme,
|
|
43
|
+
} = require("./modules/chui_services/chui_services");
|
|
44
|
+
const { I18n } = require("./modules/chui_i18n/chui_i18n");
|
|
45
|
+
const { Log } = require("./modules/chui_logger/chui_logger");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Путь приложения по имени из белого списка.
|
|
49
|
+
*
|
|
50
|
+
* Одно правило на два канала (`APP_PATH` и `APP_PATH_SYNC`): странице не должен быть доступен
|
|
51
|
+
* произвольный каталог системы, поэтому всё, что вне `APP_PATH_NAMES`, main отклоняет. Сам
|
|
52
|
+
* `app.getPath` синхронный и без диска — именно поэтому путь можно отдавать значением.
|
|
53
|
+
*/
|
|
54
|
+
function appPathByName(name = String()) {
|
|
55
|
+
if (APP_PATH_NAMES.includes(name) === false) throw new Error(`Desktop.app.path: имя «${name}» вне белого списка`);
|
|
56
|
+
return app.getPath(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//VARS
|
|
60
|
+
let isQuiting = false;
|
|
61
|
+
|
|
62
|
+
class Main {
|
|
63
|
+
/** Имя главного окна в реестре: `main.getWindow()` возвращает именно его */
|
|
64
|
+
static MAIN_WINDOW = "main";
|
|
65
|
+
/** Preload безопасного режима (contextBridge) */
|
|
66
|
+
static SECURE_PRELOAD = path.join(__dirname, "framework/modules/chui_bridge/preload.js");
|
|
67
|
+
/**
|
|
68
|
+
* Строгая политика для безопасных окон: скрипты и стили только свои (и файлы приложения),
|
|
69
|
+
* никаких внешних источников, `connect-src 'none'` — окну не нужна сеть.
|
|
70
|
+
*/
|
|
71
|
+
static SECURE_CSP =
|
|
72
|
+
"default-src 'none'; script-src 'self' file:; style-src 'self' file: 'unsafe-inline'; img-src 'self' file: data: blob:; font-src 'self' file: data:; media-src 'self' file: data: blob:; connect-src 'none'";
|
|
73
|
+
/**
|
|
74
|
+
* Разметка страницы окна: пустая оболочка без собственного кода — всё приходит из
|
|
75
|
+
* preload'а приложения (обычный режим) или из моста (`security: {contextIsolation: true}`,
|
|
76
|
+
* он подключает `render` как `<script>` документа). Файлом в пакете не лежит: страница
|
|
77
|
+
* пишется в userData при первом окне — см. `#windowPage()`. Окно живёт на `file:`
|
|
78
|
+
* документе, потому что только так Chromium разрешает подключать файлы фреймворка.
|
|
79
|
+
*/
|
|
80
|
+
static WINDOW_PAGE =
|
|
81
|
+
'<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><title>app</title></head><body></body></html>';
|
|
82
|
+
/** Путь к уже записанной странице окна: файл готовится один раз на процесс */
|
|
83
|
+
static #windowPagePath = undefined;
|
|
84
|
+
#tray = undefined;
|
|
85
|
+
//
|
|
86
|
+
#appIcon = undefined;
|
|
87
|
+
#window = undefined;
|
|
88
|
+
// Реестр окон: имя → BrowserWindow (дополнительные окна создаёт createWindow())
|
|
89
|
+
#windows = new Map();
|
|
90
|
+
#beforeQuitBound = false;
|
|
91
|
+
#webRequestBound = false;
|
|
92
|
+
//
|
|
93
|
+
#appName = undefined;
|
|
94
|
+
#height = undefined;
|
|
95
|
+
#width = undefined;
|
|
96
|
+
#minHeight = undefined;
|
|
97
|
+
#minWidth = undefined;
|
|
98
|
+
#renderer = undefined;
|
|
99
|
+
#devTools = false;
|
|
100
|
+
#devToolsOpened = false;
|
|
101
|
+
#webSecurity = true;
|
|
102
|
+
#resizable = true;
|
|
103
|
+
// IPC: обработчики регистрируются один раз и снимаются в stop()/restart()
|
|
104
|
+
#ipcRegistered = false;
|
|
105
|
+
#updaterListening = false;
|
|
106
|
+
#lastUpdateInfo = undefined;
|
|
107
|
+
#onShowSystemNotification = (e, title, body) => {
|
|
108
|
+
const { Notification } = require("electron");
|
|
109
|
+
new Notification({ title, body }).show();
|
|
110
|
+
};
|
|
111
|
+
#onSendLogText = (e, text, type) => {
|
|
112
|
+
if (type === "info") {
|
|
113
|
+
console.info(text);
|
|
114
|
+
} else if (type === "error") {
|
|
115
|
+
console.error(text);
|
|
116
|
+
}
|
|
117
|
+
this.#writeLog(text);
|
|
118
|
+
};
|
|
119
|
+
#onUpdateInstallConfirm = (e, check) => {
|
|
120
|
+
if (check) {
|
|
121
|
+
Log.info("Установка обновления...");
|
|
122
|
+
require("electron-updater").autoUpdater.quitAndInstall();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
#onUpdateDownloaded = async () => {
|
|
126
|
+
await this.#sendNotificationUpdateLoadClose();
|
|
127
|
+
await this.#sendNotificationUpdate(this.#appName, I18n.t("appLayout.update.downloaded"));
|
|
128
|
+
setTimeout(async () => await this.#sendNotificationUpdateClose(), 3000);
|
|
129
|
+
Log.info("Обновление скачано!");
|
|
130
|
+
const version =
|
|
131
|
+
this.#lastUpdateInfo === undefined ? app.getVersion() : this.#lastUpdateInfo.versionInfo.version;
|
|
132
|
+
this.#window.webContents.send("checkUpdatesTrue", true, version);
|
|
133
|
+
};
|
|
134
|
+
// Глобальные обработчики ошибок процесса: регистрируются один раз, снимаются в stop()/restart()
|
|
135
|
+
#processHandlersRegistered = false;
|
|
136
|
+
// Десктоп-сервисы (буфер, недавние, прогресс, глобальные клавиши) и их подписки по окнам
|
|
137
|
+
#servicesEnabled = false;
|
|
138
|
+
#serviceHandlers = [];
|
|
139
|
+
// Синхронные каналы снимаются не `removeHandler`, а `removeListener`: они зарегистрированы
|
|
140
|
+
// через `ipcMain.on` — см. `#handleSync`
|
|
141
|
+
#serviceSyncHandlers = [];
|
|
142
|
+
#shortcutSubscribers = new Map();
|
|
143
|
+
#shortcutWatchers = new Map();
|
|
144
|
+
#progressWindows = new Map();
|
|
145
|
+
#onThemeUpdated = undefined;
|
|
146
|
+
// Безопасный режим: preload-мост, contextIsolation и CSP вместо Node в рендерере
|
|
147
|
+
#security = { contextIsolation: false, csp: false, channels: [] };
|
|
148
|
+
#secureContents = new Set();
|
|
149
|
+
#bridgeEnabled = false;
|
|
150
|
+
#windowActionsBound = false;
|
|
151
|
+
#themeBound = false;
|
|
152
|
+
#pageMessageHandlers = new Map();
|
|
153
|
+
#onUncaughtException = (error) => {
|
|
154
|
+
Main.#logProcessError("Необработанное исключение", error);
|
|
155
|
+
};
|
|
156
|
+
#onUnhandledRejection = (reason) => {
|
|
157
|
+
Main.#logProcessError("Необработанный reject промиса", reason);
|
|
158
|
+
};
|
|
159
|
+
static #logProcessError(prefix, value) {
|
|
160
|
+
const text = `${prefix}: ${Main.#formatError(value)}`;
|
|
161
|
+
try {
|
|
162
|
+
Log.error(text);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// Сам обработчик ошибок падать не должен (например, недоступен userData для лога)
|
|
165
|
+
console.error(text, e);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
static #formatError(value) {
|
|
169
|
+
if (value === undefined || value === null) return I18n.t("appLayout.error.unknown");
|
|
170
|
+
if (value.stack !== undefined) return value.stack;
|
|
171
|
+
return String(value);
|
|
172
|
+
}
|
|
173
|
+
// ПУТИ: папка загрузок из options.paths — применяется, только если приложение её задало
|
|
174
|
+
#downloadPath = undefined;
|
|
175
|
+
#downloadBound = false;
|
|
176
|
+
// Загрузка в заданную папку: имя файла берём у Electron, существующее не перезаписываем
|
|
177
|
+
#onWillDownload = (event, item) => {
|
|
178
|
+
if (this.#downloadPath === undefined) return;
|
|
179
|
+
if (item === undefined || typeof item.setSavePath !== "function") return;
|
|
180
|
+
const target = path.join(this.#downloadPath, item.getFilename());
|
|
181
|
+
if (fs.existsSync(target)) return;
|
|
182
|
+
try {
|
|
183
|
+
if (!fs.existsSync(this.#downloadPath)) fs.mkdirSync(this.#downloadPath, { recursive: true });
|
|
184
|
+
item.setSavePath(target);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
Log.error(`Не удалось сохранить загрузку в ${this.#downloadPath}: ${Main.#formatError(error)}`);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
constructor(
|
|
191
|
+
options = {
|
|
192
|
+
name: String(),
|
|
193
|
+
sizes: { height: Number(), width: Number(), minHeight: Number(), minWidth: Number() },
|
|
194
|
+
render: String(),
|
|
195
|
+
devTools: Boolean(),
|
|
196
|
+
webSecurity: Boolean(),
|
|
197
|
+
resizable: Boolean(),
|
|
198
|
+
security: { contextIsolation: Boolean(), csp: Boolean(), channels: [] },
|
|
199
|
+
paths: {
|
|
200
|
+
downloadPath: String(),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
) {
|
|
204
|
+
this.#appIcon = options.icon;
|
|
205
|
+
if (this.#appIcon === undefined) {
|
|
206
|
+
if (process.platform === "darwin") {
|
|
207
|
+
this.#appIcon = Main.#resizeIconTray(getDefaultIcon());
|
|
208
|
+
} else {
|
|
209
|
+
this.#appIcon = getDefaultIcon();
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
if (process.platform === "darwin") {
|
|
213
|
+
this.#appIcon = Main.#resizeIconTray(options.icon);
|
|
214
|
+
} else {
|
|
215
|
+
this.#appIcon = options.icon;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// app.commandLine.appendSwitch('--enable-features', 'OverlayScrollbar')
|
|
219
|
+
app.commandLine.appendSwitch("disable-hid-blocklist");
|
|
220
|
+
app.commandLine.appendSwitch("enable-features=OverlayScrollbar");
|
|
221
|
+
// app.commandLine.appendSwitch("disable-http-cache");
|
|
222
|
+
app.commandLine.appendSwitch("enable-transparent-visuals");
|
|
223
|
+
app.commandLine.appendSwitch("enable-gpu-rasterization", "true");
|
|
224
|
+
app.commandLine.appendSwitch("enable-native-gpu-memory-buffers", "true");
|
|
225
|
+
app.commandLine.appendSwitch("high-dpi-support", "true");
|
|
226
|
+
app.commandLine.appendSwitch("device-scale-factor", "true");
|
|
227
|
+
app.commandLine.appendSwitch("disable-touch-adjustment", "true");
|
|
228
|
+
app.commandLine.appendSwitch("main-frame-resizes-are-orientation-changes", "true");
|
|
229
|
+
app.commandLine.appendSwitch("disable-pinch", "true");
|
|
230
|
+
// Страховка на случай широкоохватного экрана: токены темы заданы в sRGB, и без этого флага
|
|
231
|
+
// Chromium пересчитывает их в профиль монитора — число в пипетке перестаёт совпадать с тем,
|
|
232
|
+
// что написано в `main_theme_style.css`. Сам по себе сдвиг тёмных тонов на Wayland он не
|
|
233
|
+
// лечит (см. ниже), поэтому это именно страховка, а не причина той правки.
|
|
234
|
+
app.commandLine.appendSwitch("force-color-profile", "srgb");
|
|
235
|
+
// На сеансе Wayland Chromium ведёт окно через протокол цветоуправления `wp_color_manager_v1`
|
|
236
|
+
// и поднимает тёмные тона: вместо токена `#1c1c1e` из `main_theme_style.css` на экране
|
|
237
|
+
// `#222223` — замер пикселя, а чёрный и белый при этом на месте, то есть кривая, а не
|
|
238
|
+
// прозрачность. С отключённым протоколом сдвиг +6 → 0, нативный Wayland остаётся.
|
|
239
|
+
// Значение флага читается из командной строки, поэтому своё добавляем к уже переданному.
|
|
240
|
+
if (
|
|
241
|
+
process.platform === "linux" &&
|
|
242
|
+
(process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined)
|
|
243
|
+
) {
|
|
244
|
+
const passed = app.commandLine.getSwitchValue("disable-features");
|
|
245
|
+
app.commandLine.appendSwitch(
|
|
246
|
+
"disable-features",
|
|
247
|
+
passed === "" ? "WaylandWpColorManagerV1" : `${passed},WaylandWpColorManagerV1`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Без опции загрузки идут обычным путём Electron: раньше значение вычислялось и не использовалось
|
|
252
|
+
this.#downloadPath = options.paths === undefined ? undefined : options.paths.downloadPath;
|
|
253
|
+
|
|
254
|
+
// app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
|
255
|
+
|
|
256
|
+
// Options
|
|
257
|
+
this.#appName = options.name;
|
|
258
|
+
this.#height = options.sizes.height;
|
|
259
|
+
this.#width = options.sizes.width;
|
|
260
|
+
this.#minHeight = options.sizes.minHeight;
|
|
261
|
+
this.#minWidth = options.sizes.minWidth;
|
|
262
|
+
this.#renderer = options.render;
|
|
263
|
+
this.#webSecurity = options.webSecurity;
|
|
264
|
+
this.#resizable = options.resizable;
|
|
265
|
+
this.#devTools = options.devTools;
|
|
266
|
+
if (options.security !== undefined) this.#security = Main.#normalizeSecurity(options.security);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static #resizeIconTray(image) {
|
|
270
|
+
return require("electron").nativeImage.createFromPath(image).resize({ width: 16, height: 16 });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Опции безопасности окна: по умолчанию включён привычный режим (Node в рендерере) */
|
|
274
|
+
static #normalizeSecurity(security = Object()) {
|
|
275
|
+
return {
|
|
276
|
+
contextIsolation: security.contextIsolation === true,
|
|
277
|
+
csp: security.csp === undefined ? true : security.csp,
|
|
278
|
+
channels: security.channels === undefined ? [] : security.channels,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** CSP окна: `true` — политика фреймворка, строка — своя, `false` — без политики */
|
|
283
|
+
static #cspFor(security = Object()) {
|
|
284
|
+
if (security.contextIsolation !== true) return undefined;
|
|
285
|
+
if (security.csp === false || security.csp === undefined || security.csp === null) return undefined;
|
|
286
|
+
return security.csp === true ? Main.SECURE_CSP : String(security.csp);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Аргументы preload'а моста: путь страницы, белый список каналов и CSP
|
|
290
|
+
static #bridgeArguments(render = undefined, security = Object()) {
|
|
291
|
+
const args = [];
|
|
292
|
+
if (render !== undefined) args.push(`${BRIDGE_ARGS.RENDER}${render}`);
|
|
293
|
+
if (security.channels !== undefined && security.channels.length > 0) {
|
|
294
|
+
args.push(`${BRIDGE_ARGS.CHANNELS}${security.channels.join(",")}`);
|
|
295
|
+
}
|
|
296
|
+
const csp = Main.#cspFor(security);
|
|
297
|
+
if (csp !== undefined) args.push(`${BRIDGE_ARGS.CSP}${csp}`);
|
|
298
|
+
return args;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Путь к файлу страницы окна. Разметка держится строкой (`WINDOW_PAGE`), а окно грузится
|
|
303
|
+
* только с `file:` документа (у `data:`-документа Chromium запрещает подресурсы
|
|
304
|
+
* `file:` — «Not allowed to load local resource»), поэтому файл пишется в userData и
|
|
305
|
+
* перезаписывается, если его содержимое отличается от текущей разметки.
|
|
306
|
+
*/
|
|
307
|
+
static #windowPage() {
|
|
308
|
+
if (Main.#windowPagePath !== undefined) return Main.#windowPagePath;
|
|
309
|
+
const page = path.join(app.getPath("userData"), "chui_window.html");
|
|
310
|
+
try {
|
|
311
|
+
if (!fs.existsSync(page) || fs.readFileSync(page, "utf8") !== Main.WINDOW_PAGE) {
|
|
312
|
+
fs.writeFileSync(page, Main.WINDOW_PAGE, "utf8");
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// Без страницы окно не создать: ошибка должна дойти до вызывающего с понятным текстом
|
|
316
|
+
throw new Error(`Не удалось создать страницу окна «${page}»: ${Main.#formatError(error)}`, {
|
|
317
|
+
cause: error,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
Main.#windowPagePath = page;
|
|
321
|
+
return page;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
static #keyToChangeLower(obj, keyToChange, value) {
|
|
325
|
+
const keyToChangeLower = keyToChange.toLowerCase();
|
|
326
|
+
for (const key of Object.keys(obj)) {
|
|
327
|
+
if (key.toLowerCase() === keyToChangeLower) {
|
|
328
|
+
obj[key] = value;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
obj[keyToChange] = value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Создаёт окно и ставит его в реестр. Главное окно (`start()`) и дополнительные
|
|
337
|
+
* (`createWindow()`) собираются одним кодом: у всех окон одинаковые webPreferences,
|
|
338
|
+
* иконка, поведение закрытия и подписка на `chui_resizable_false`.
|
|
339
|
+
*/
|
|
340
|
+
#buildWindow(
|
|
341
|
+
options = {
|
|
342
|
+
name: String(),
|
|
343
|
+
title: String(),
|
|
344
|
+
sizes: Object(),
|
|
345
|
+
render: String(),
|
|
346
|
+
parent: undefined,
|
|
347
|
+
security: Object(),
|
|
348
|
+
hideOnClose: Boolean(),
|
|
349
|
+
resizable: Boolean(),
|
|
350
|
+
devTools: Boolean(),
|
|
351
|
+
}
|
|
352
|
+
) {
|
|
353
|
+
const sizes = options.sizes === undefined ? {} : options.sizes;
|
|
354
|
+
const name = options.name === undefined ? Main.MAIN_WINDOW : options.name;
|
|
355
|
+
const resizable = options.resizable === undefined ? this.#resizable : options.resizable;
|
|
356
|
+
// У окна может быть своя политика: `createWindow({security: {...}})`
|
|
357
|
+
const security = options.security === undefined ? this.#security : Main.#normalizeSecurity(options.security);
|
|
358
|
+
const secure = security.contextIsolation === true;
|
|
359
|
+
this.#window = new BrowserWindow({
|
|
360
|
+
transparent: false,
|
|
361
|
+
width: sizes.width === undefined ? this.#width : sizes.width,
|
|
362
|
+
height: sizes.height === undefined ? this.#height : sizes.height,
|
|
363
|
+
minHeight: sizes.minHeight === undefined ? this.#minHeight : sizes.minHeight,
|
|
364
|
+
minWidth: sizes.minWidth === undefined ? this.#minWidth : sizes.minWidth,
|
|
365
|
+
name: name,
|
|
366
|
+
title: options.title === undefined ? this.#appName : options.title,
|
|
367
|
+
show: false,
|
|
368
|
+
icon: this.#appIcon,
|
|
369
|
+
roundedCorners: true,
|
|
370
|
+
// Безопасный режим: в preload идёт мост фреймворка, а скрипт приложения
|
|
371
|
+
// выполняется как обычный <script> документа (см. chui_bridge/preload.js)
|
|
372
|
+
webPreferences: secure
|
|
373
|
+
? {
|
|
374
|
+
plugins: false,
|
|
375
|
+
nodeIntegration: false,
|
|
376
|
+
contextIsolation: true,
|
|
377
|
+
sandbox: false,
|
|
378
|
+
preload: Main.SECURE_PRELOAD,
|
|
379
|
+
additionalArguments: Main.#bridgeArguments(options.render, security),
|
|
380
|
+
spellcheck: false,
|
|
381
|
+
webviewTag: false,
|
|
382
|
+
webSecurity: this.#webSecurity,
|
|
383
|
+
nodeIntegrationInSubFrames: false,
|
|
384
|
+
}
|
|
385
|
+
: {
|
|
386
|
+
plugins: false,
|
|
387
|
+
nodeIntegration: true,
|
|
388
|
+
contextIsolation: false,
|
|
389
|
+
// Рендерер окна: у дополнительных окон может быть свой скрипт
|
|
390
|
+
preload: options.render === undefined ? this.#renderer : options.render,
|
|
391
|
+
spellcheck: false,
|
|
392
|
+
webviewTag: true,
|
|
393
|
+
webSecurity: this.#webSecurity,
|
|
394
|
+
nodeIntegrationInSubFrames: true,
|
|
395
|
+
},
|
|
396
|
+
frame: false,
|
|
397
|
+
resizable: resizable,
|
|
398
|
+
minimizable: true,
|
|
399
|
+
maximizable: true,
|
|
400
|
+
center: true,
|
|
401
|
+
hasShadow: true,
|
|
402
|
+
parent: options.parent,
|
|
403
|
+
});
|
|
404
|
+
const window = this.#window;
|
|
405
|
+
const webContentsId = window.webContents.id;
|
|
406
|
+
const pageUrl = url.pathToFileURL(Main.#windowPage()).href;
|
|
407
|
+
this.#windows.set(name, window);
|
|
408
|
+
if (secure) {
|
|
409
|
+
this.#secureContents.add(webContentsId);
|
|
410
|
+
this.enableBridge();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this.#bindWebRequest(window);
|
|
414
|
+
// Навигация ограничена страницей приложения: у окна нет адресной строки и меню,
|
|
415
|
+
// и уехать на чужой сайт значило бы потерять приложение без выхода назад.
|
|
416
|
+
// `window.open` и `target="_blank"` (ссылки `Link`) уходят в системный браузер,
|
|
417
|
+
// а не в новое окно Electron с чужими `webPreferences`.
|
|
418
|
+
window.webContents.setWindowOpenHandler(({ url: target }) => {
|
|
419
|
+
Main.#openExternalTarget(target);
|
|
420
|
+
return { action: "deny" };
|
|
421
|
+
});
|
|
422
|
+
window.webContents.on("will-navigate", (event, target) => {
|
|
423
|
+
if (target === pageUrl) return;
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
Main.#openExternalTarget(target);
|
|
426
|
+
});
|
|
427
|
+
window
|
|
428
|
+
// Путь из userData может содержать пробелы и кириллицу — приводим его к file: URL
|
|
429
|
+
.loadURL(pageUrl)
|
|
430
|
+
.then(() => this.#bindBeforeQuit())
|
|
431
|
+
.catch((error) => {
|
|
432
|
+
// Окно закрыли до конца загрузки: навигация прерывается (ERR_FAILED/ERR_ABORTED),
|
|
433
|
+
// это не ошибка загрузки и в лог попадать не должна
|
|
434
|
+
if (window.isDestroyed() || this.#windows.get(name) !== window) return;
|
|
435
|
+
const code = String((error && error.code) || "");
|
|
436
|
+
if (code === "ERR_ABORTED" || code === "ERR_FAILED") return;
|
|
437
|
+
Main.#logProcessError("Не удалось загрузить окно", error);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
if ((options.devTools === undefined ? this.#devTools : options.devTools) === true) {
|
|
441
|
+
window.webContents.openDevTools();
|
|
442
|
+
if (name === Main.MAIN_WINDOW) this.#devToolsOpened = true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Окно могли закрыть раньше, чем истекла задержка показа: show() у уничтоженного
|
|
446
|
+
// окна бросает «Object has been destroyed» прямо в таймере и всплывает в main
|
|
447
|
+
window.on("ready-to-show", () =>
|
|
448
|
+
setTimeout(() => {
|
|
449
|
+
if (!window.isDestroyed()) window.show();
|
|
450
|
+
}, 650)
|
|
451
|
+
);
|
|
452
|
+
// Подписки глобальных клавиш привязаны к отправителю: окно закрылось — снимаем их
|
|
453
|
+
window.on("closed", () => {
|
|
454
|
+
if (this.#windows.get(name) === window) this.#windows.delete(name);
|
|
455
|
+
this.#secureContents.delete(webContentsId);
|
|
456
|
+
this.#dropShortcutSubscriptions(webContentsId);
|
|
457
|
+
this.#progressWindows.delete(webContentsId);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
if (options.hideOnClose === true) {
|
|
461
|
+
window.on("close", (evt) => {
|
|
462
|
+
if (!isQuiting) evt.preventDefault();
|
|
463
|
+
window.hide();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (resizable === false) {
|
|
467
|
+
window.webContents.on("did-finish-load", () => window.webContents.send("chui_resizable_false"));
|
|
468
|
+
}
|
|
469
|
+
return window;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Открывает внешнюю ссылку в системном браузере.
|
|
474
|
+
*
|
|
475
|
+
* Зовётся при `window.open` и попытке увести окно на другой адрес. Наружу отдаём только
|
|
476
|
+
* `http(s)`: `file:`, `javascript:` и прочие схемы — не то, что должно попадать в браузер.
|
|
477
|
+
*/
|
|
478
|
+
static #openExternalTarget(target) {
|
|
479
|
+
if (typeof target !== "string" || !/^https?:\/\//i.test(target)) return;
|
|
480
|
+
shell.openExternal(target).catch((error) => Main.#logProcessError("Не удалось открыть ссылку", error));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// `before-quit` нужен один раз на приложение, а не на каждое созданное окно
|
|
484
|
+
#bindBeforeQuit() {
|
|
485
|
+
if (this.#beforeQuitBound) return;
|
|
486
|
+
this.#beforeQuitBound = true;
|
|
487
|
+
app.on("before-quit", () => {
|
|
488
|
+
isQuiting = true;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Обход CORS при `webSecurity: false`: у сессии один слушатель, а не по одному на окно
|
|
493
|
+
#bindWebRequest(window) {
|
|
494
|
+
if (this.#webSecurity || this.#webRequestBound) return;
|
|
495
|
+
this.#webRequestBound = true;
|
|
496
|
+
window.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
|
497
|
+
const { requestHeaders } = details;
|
|
498
|
+
Main.#keyToChangeLower(requestHeaders, "Access-Control-Allow-Origin", ["*"]);
|
|
499
|
+
Main.#keyToChangeLower(requestHeaders, "X-Frame-Options", ["*"]);
|
|
500
|
+
callback({ requestHeaders });
|
|
501
|
+
});
|
|
502
|
+
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
|
503
|
+
const { responseHeaders } = details;
|
|
504
|
+
Main.#keyToChangeLower(responseHeaders, "Access-Control-Allow-Origin", ["*"]);
|
|
505
|
+
Main.#keyToChangeLower(responseHeaders, "Access-Control-Allow-Headers", ["*"]);
|
|
506
|
+
Main.#keyToChangeLower(responseHeaders, "X-Frame-Options", ["*"]);
|
|
507
|
+
callback({
|
|
508
|
+
responseHeaders,
|
|
509
|
+
cancel: false,
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Дополнительное окно приложения: свой скрипт рендерера (`render`), свой layout
|
|
516
|
+
* (`AppLayout` регистрирует узлы в своём документе — см. `chui_layout`) и своя запись
|
|
517
|
+
* в реестре окон. Возвращает `BrowserWindow`, поэтому вызывать нужно с `await`:
|
|
518
|
+
* окно создаётся только после `app.whenReady()`.
|
|
519
|
+
*
|
|
520
|
+
* ```javascript
|
|
521
|
+
* const win = await main.createWindow({ name: 'preview', title: 'Просмотр',
|
|
522
|
+
* sizes: {width: 900, height: 600}, render: `${__dirname}/app/preview.js` });
|
|
523
|
+
* main.sendTo('preview', 'preview_update', payload);
|
|
524
|
+
* ```
|
|
525
|
+
*/
|
|
526
|
+
async createWindow(
|
|
527
|
+
options = {
|
|
528
|
+
name: String(),
|
|
529
|
+
title: String(),
|
|
530
|
+
sizes: Object(),
|
|
531
|
+
render: String(),
|
|
532
|
+
parent: undefined,
|
|
533
|
+
security: Object(),
|
|
534
|
+
hideOnClose: Boolean(),
|
|
535
|
+
resizable: Boolean(),
|
|
536
|
+
devTools: Boolean(),
|
|
537
|
+
}
|
|
538
|
+
) {
|
|
539
|
+
await app.whenReady();
|
|
540
|
+
this.enableServices();
|
|
541
|
+
return this.#buildWindow(options);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Окно по имени реестра; без имени или с `Main.MAIN_WINDOW` — главное окно */
|
|
545
|
+
getWindow(name = Main.MAIN_WINDOW) {
|
|
546
|
+
return this.#windows.get(name);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Главное окно приложения (создаётся в `start()`) */
|
|
550
|
+
getMainWindow() {
|
|
551
|
+
return this.#windows.get(Main.MAIN_WINDOW);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Все открытые окна: `[{name, window, secure}]` в порядке создания */
|
|
555
|
+
getWindows() {
|
|
556
|
+
return [...this.#windows].map(([name, window]) => ({
|
|
557
|
+
name: name,
|
|
558
|
+
window: window,
|
|
559
|
+
secure: this.#secureContents.has(window.webContents.id),
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
focusWindow(name = Main.MAIN_WINDOW) {
|
|
564
|
+
const window = this.#windows.get(name);
|
|
565
|
+
if (window === undefined) return false;
|
|
566
|
+
if (window.isMinimized()) window.restore();
|
|
567
|
+
window.show();
|
|
568
|
+
window.focus();
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Закрывает окно (не приложение) и убирает его из реестра */
|
|
573
|
+
closeWindow(name = Main.MAIN_WINDOW) {
|
|
574
|
+
const window = this.#windows.get(name);
|
|
575
|
+
if (window === undefined) return false;
|
|
576
|
+
window.close();
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Сообщение в конкретное окно; без имени — в главное */
|
|
581
|
+
sendTo(name = Main.MAIN_WINDOW, channel = String(), ...args) {
|
|
582
|
+
const window = this.#windows.get(name);
|
|
583
|
+
if (window === undefined || window.isDestroyed()) return false;
|
|
584
|
+
window.webContents.send(channel, ...args);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Папка загрузок из `options.paths.downloadPath`: одна подписка на сессию, снимается
|
|
590
|
+
* в `#removeGlobalHandlers()`. Без опции Electron выбирает путь загрузки сам.
|
|
591
|
+
*/
|
|
592
|
+
#bindDownloadPath() {
|
|
593
|
+
if (this.#downloadPath === undefined || this.#downloadBound) return;
|
|
594
|
+
this.#downloadBound = true;
|
|
595
|
+
session.defaultSession.on("will-download", this.#onWillDownload);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Включает десктоп-сервисы: обработчики IPC для `Desktop` (буфер, недавние документы,
|
|
600
|
+
* прогресс на иконке, глобальные клавиши) и сами сервисы main-процесса.
|
|
601
|
+
* Вызывается один раз из `start()` и `createWindow()`.
|
|
602
|
+
*/
|
|
603
|
+
enableServices() {
|
|
604
|
+
if (this.#servicesEnabled) return this;
|
|
605
|
+
this.#servicesEnabled = true;
|
|
606
|
+
// Кнопки окна: каналы нужны и обычным окнам — в них `AppLayout` зовёт их по IPC,
|
|
607
|
+
// потому что `@electron/remote` в рендерере больше нет
|
|
608
|
+
this.#enableWindowActions();
|
|
609
|
+
this.services = {
|
|
610
|
+
shortcuts: new Shortcuts(),
|
|
611
|
+
shell: new Shell(),
|
|
612
|
+
clipboard: new Clipboard(),
|
|
613
|
+
recent: new RecentDocuments(),
|
|
614
|
+
progress: new TaskbarProgress(),
|
|
615
|
+
theme: new Theme(store),
|
|
616
|
+
locale: new Locale(store),
|
|
617
|
+
clipboard_history: new ClipboardHistoryService({ store: store }),
|
|
618
|
+
print: new Print(),
|
|
619
|
+
};
|
|
620
|
+
// Сохранённая тема применяется до создания окна: иначе при запуске была бы видна
|
|
621
|
+
// вспышка системной палитры
|
|
622
|
+
this.services.theme.restore();
|
|
623
|
+
// Язык применяется и в main-процессе: его строки (меню, уведомления об обновлении)
|
|
624
|
+
// собираются там же, где живёт сервис локали
|
|
625
|
+
this.services.locale.restore();
|
|
626
|
+
I18n.setLocale(this.services.locale.get(), true);
|
|
627
|
+
this.#bindThemeEvents();
|
|
628
|
+
this.#bindDownloadPath();
|
|
629
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_READ_TEXT, () => this.services.clipboard.readText());
|
|
630
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_WRITE_TEXT, (event, text) => {
|
|
631
|
+
this.services.clipboard.writeText(text);
|
|
632
|
+
// Всё, что приложение кладёт в буфер само, должно попасть в историю
|
|
633
|
+
this.services.clipboard_history.add(text);
|
|
634
|
+
this.#emitClipboardHistory();
|
|
635
|
+
return true;
|
|
636
|
+
});
|
|
637
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_HISTORY_LIST, () => this.services.clipboard_history.list());
|
|
638
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_HISTORY_ADD, (event, text) => {
|
|
639
|
+
this.services.clipboard_history.add(text);
|
|
640
|
+
this.#emitClipboardHistory();
|
|
641
|
+
return this.services.clipboard_history.list();
|
|
642
|
+
});
|
|
643
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_HISTORY_COPY, (event, text) => {
|
|
644
|
+
this.services.clipboard_history.copy(text);
|
|
645
|
+
this.#emitClipboardHistory();
|
|
646
|
+
return this.services.clipboard_history.list();
|
|
647
|
+
});
|
|
648
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_HISTORY_REMOVE, (event, target) => {
|
|
649
|
+
this.services.clipboard_history.remove(target);
|
|
650
|
+
this.#emitClipboardHistory();
|
|
651
|
+
return this.services.clipboard_history.list();
|
|
652
|
+
});
|
|
653
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_HISTORY_CLEAR, () => {
|
|
654
|
+
this.services.clipboard_history.clear();
|
|
655
|
+
this.#emitClipboardHistory();
|
|
656
|
+
return this.services.clipboard_history.list();
|
|
657
|
+
});
|
|
658
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_HAS, (event, format) => this.services.clipboard.has(format));
|
|
659
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_READ_IMAGE, () => this.services.clipboard.readImageDataURL());
|
|
660
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_WRITE_IMAGE, (event, dataURL) => {
|
|
661
|
+
this.services.clipboard.writeImageDataURL(dataURL);
|
|
662
|
+
return true;
|
|
663
|
+
});
|
|
664
|
+
this.#handle(SERVICES_CHANNELS.CLIPBOARD_CLEAR, () => {
|
|
665
|
+
this.services.clipboard.clear();
|
|
666
|
+
return true;
|
|
667
|
+
});
|
|
668
|
+
// Приложение и системные диалоги для страницы: Electron советует IPC вместо `remote`.
|
|
669
|
+
// Путь — только из белого списка, диалог и сессия — у окна-отправителя.
|
|
670
|
+
this.#handle(SERVICES_CHANNELS.APP_PATH, (event, name) => appPathByName(name));
|
|
671
|
+
// `Desktop.app.path` отвечает значением, а не промисом, поэтому у пути есть второй,
|
|
672
|
+
// синхронный канал: `sendSync` ждёт `event.returnValue`, а не ответ `handle`.
|
|
673
|
+
// Асинхронный канал выше оставлен — его ключ есть в карте `window.chui.services`
|
|
674
|
+
this.#handleSync(SERVICES_CHANNELS.APP_PATH_SYNC, (event, name) => appPathByName(name));
|
|
675
|
+
this.#handle(SERVICES_CHANNELS.APP_INFO, () => ({
|
|
676
|
+
name: app.getName(),
|
|
677
|
+
version: app.getVersion(),
|
|
678
|
+
locale: app.getLocale(),
|
|
679
|
+
systemLocale: app.getSystemLocale(),
|
|
680
|
+
isPackaged: app.isPackaged,
|
|
681
|
+
}));
|
|
682
|
+
// Закрытие и перезапуск со страницы: страница — код самого приложения, поэтому кнопке
|
|
683
|
+
// «Выход» не нужен свой канал в каждом проекте. Идут через `Main.stop()`/`restart()` —
|
|
684
|
+
// те же шаги, что у пункта меню: снимаются обработчики сервисов, глобальные клавиши и
|
|
685
|
+
// подписки, а не просто закрывается окно. Ответ уходит странице до выхода
|
|
686
|
+
// (`setImmediate`): иначе промис отваливался бы с «Render frame was disposed», и
|
|
687
|
+
// `await Desktop.app.quit()` со страницы выглядел бы ошибкой
|
|
688
|
+
this.#handle(SERVICES_CHANNELS.APP_QUIT, () => {
|
|
689
|
+
setImmediate(() => this.stop());
|
|
690
|
+
return true;
|
|
691
|
+
});
|
|
692
|
+
this.#handle(SERVICES_CHANNELS.APP_RESTART, () => {
|
|
693
|
+
setImmediate(() => this.restart());
|
|
694
|
+
return true;
|
|
695
|
+
});
|
|
696
|
+
// У закрытия и перезапуска есть и синхронные каналы: `Desktop.app.quit()`/`restart()`
|
|
697
|
+
// отвечают значением, а не промисом — как `Desktop.app.path`. Выход по-прежнему
|
|
698
|
+
// откладывается `setImmediate`, чтобы `event.returnValue` успел уйти странице.
|
|
699
|
+
this.#handleSync(SERVICES_CHANNELS.APP_QUIT_SYNC, () => {
|
|
700
|
+
setImmediate(() => this.stop());
|
|
701
|
+
return true;
|
|
702
|
+
});
|
|
703
|
+
this.#handleSync(SERVICES_CHANNELS.APP_RESTART_SYNC, () => {
|
|
704
|
+
setImmediate(() => this.restart());
|
|
705
|
+
return true;
|
|
706
|
+
});
|
|
707
|
+
this.#handle(SERVICES_CHANNELS.DIALOG_OPEN, (event, options) =>
|
|
708
|
+
Main.#showDialog(event, "showOpenDialog", options)
|
|
709
|
+
);
|
|
710
|
+
this.#handle(SERVICES_CHANNELS.DIALOG_SAVE, (event, options) =>
|
|
711
|
+
Main.#showDialog(event, "showSaveDialog", options)
|
|
712
|
+
);
|
|
713
|
+
this.#handle(SERVICES_CHANNELS.DIALOG_MESSAGE, (event, options) =>
|
|
714
|
+
Main.#showDialog(event, "showMessageBox", options)
|
|
715
|
+
);
|
|
716
|
+
this.#handle(SERVICES_CHANNELS.SESSION_CACHE_SIZE, (event) => event.sender.session.getCacheSize());
|
|
717
|
+
this.#handle(SERVICES_CHANNELS.SESSION_CLEAR_CACHE, (event) => event.sender.session.clearCache());
|
|
718
|
+
this.#handle(SERVICES_CHANNELS.SESSION_CLEAR_STORAGE, (event, options) =>
|
|
719
|
+
event.sender.session.clearStorageData(options)
|
|
720
|
+
);
|
|
721
|
+
this.#handle(SERVICES_CHANNELS.RECENT_ADD, (event, path, name) => {
|
|
722
|
+
this.services.recent.add(path, name);
|
|
723
|
+
return true;
|
|
724
|
+
});
|
|
725
|
+
this.#handle(SERVICES_CHANNELS.RECENT_CLEAR, () => {
|
|
726
|
+
this.services.recent.clear();
|
|
727
|
+
return true;
|
|
728
|
+
});
|
|
729
|
+
this.#handle(SERVICES_CHANNELS.RECENT_LIST, () => this.services.recent.get());
|
|
730
|
+
this.#handle(SERVICES_CHANNELS.PROGRESS_SET, (event, value, mode) => {
|
|
731
|
+
this.#progressFor(event).set(value, mode);
|
|
732
|
+
return true;
|
|
733
|
+
});
|
|
734
|
+
this.#handle(SERVICES_CHANNELS.PROGRESS_CLEAR, (event) => {
|
|
735
|
+
this.#progressFor(event).clear();
|
|
736
|
+
return true;
|
|
737
|
+
});
|
|
738
|
+
this.#handle(SERVICES_CHANNELS.SHELL_OPEN_EXTERNAL, (event, url) => this.services.shell.openExternal(url));
|
|
739
|
+
this.#handle(SERVICES_CHANNELS.PRINT_PAGE, (event, options) =>
|
|
740
|
+
this.services.print.page(BrowserWindow.fromWebContents(event.sender), this.#printOptions(event, options))
|
|
741
|
+
);
|
|
742
|
+
this.#handle(SERVICES_CHANNELS.PRINT_HTML, (event, html, options) =>
|
|
743
|
+
this.services.print.html(html, this.#printOptions(event, options))
|
|
744
|
+
);
|
|
745
|
+
this.#handle(SERVICES_CHANNELS.PRINT_PDF, (event, html, options) =>
|
|
746
|
+
this.services.print.pdf(html, this.#printOptions(event, options))
|
|
747
|
+
);
|
|
748
|
+
this.#handle(SERVICES_CHANNELS.THEME_GET, () => ({
|
|
749
|
+
theme: this.services.theme.get(),
|
|
750
|
+
source: this.services.theme.getSource(),
|
|
751
|
+
}));
|
|
752
|
+
this.#handle(SERVICES_CHANNELS.THEME_SET, (event, source) => {
|
|
753
|
+
this.services.theme.set(source);
|
|
754
|
+
return { theme: this.services.theme.get(), source: this.services.theme.getSource() };
|
|
755
|
+
});
|
|
756
|
+
this.#handle(SERVICES_CHANNELS.THEME_TOGGLE, () => {
|
|
757
|
+
this.services.theme.toggle();
|
|
758
|
+
return { theme: this.services.theme.get(), source: this.services.theme.getSource() };
|
|
759
|
+
});
|
|
760
|
+
this.#handle(SERVICES_CHANNELS.LOCALE_GET, () => this.services.locale.get());
|
|
761
|
+
this.#handle(SERVICES_CHANNELS.LOCALE_SET, (event, locale) => {
|
|
762
|
+
const applied = this.services.locale.set(locale);
|
|
763
|
+
// Язык main-процесса меняется вместе с окнами: иначе меню и уведомления об обновлении
|
|
764
|
+
// остались бы на прежнем языке
|
|
765
|
+
I18n.setLocale(applied, true);
|
|
766
|
+
// Локаль, в отличие от темы, не имеет системного события — окна узнают о смене отсюда
|
|
767
|
+
this.#emitLocale(applied);
|
|
768
|
+
return applied;
|
|
769
|
+
});
|
|
770
|
+
this.#handle(SERVICES_CHANNELS.SHORTCUT_SUBSCRIBE, (event, accelerator) =>
|
|
771
|
+
this.#subscribeShortcut(event, accelerator)
|
|
772
|
+
);
|
|
773
|
+
this.#handle(SERVICES_CHANNELS.SHORTCUT_UNSUBSCRIBE, (event, accelerator) => {
|
|
774
|
+
this.#unsubscribeShortcut(event, accelerator);
|
|
775
|
+
return true;
|
|
776
|
+
});
|
|
777
|
+
return this;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ipcMain.handle + запись для снятия в stop()/restart()
|
|
781
|
+
#handle(channel = String(), handler = () => {}) {
|
|
782
|
+
ipcMain.handle(channel, handler);
|
|
783
|
+
this.#serviceHandlers.push([channel, handler]);
|
|
784
|
+
return this;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Синхронный канал: `ipcRenderer.sendSync` в странице ждёт `event.returnValue`, а не ответ
|
|
789
|
+
* `handle`, поэтому здесь `ipcMain.on`. Ответ — конверт `{value}`/`{error}`: у синхронного
|
|
790
|
+
* канала нет способа передать исключение, и без конверта страница получила бы `undefined`
|
|
791
|
+
* вместо причины отказа (см. `SERVICES_SYNC_CALLS` в chui_channels.js).
|
|
792
|
+
*/
|
|
793
|
+
#handleSync(channel = String(), handler = () => {}) {
|
|
794
|
+
const listener = (event, ...args) => {
|
|
795
|
+
try {
|
|
796
|
+
event.returnValue = { value: handler(event, ...args) };
|
|
797
|
+
} catch (error) {
|
|
798
|
+
event.returnValue = { error: error instanceof Error ? error.message : String(error) };
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
ipcMain.on(channel, listener);
|
|
802
|
+
this.#serviceSyncHandlers.push([channel, listener]);
|
|
803
|
+
return this;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Включает мост безопасных окон (`contextBridge`): управление окном, тема и прикладные
|
|
808
|
+
* сообщения по белому списку каналов. Вызывается сам при создании безопасного окна.
|
|
809
|
+
*/
|
|
810
|
+
/** Диалог в окне отправителя: без окна (уже закрыто) Electron зовётся без родителя */
|
|
811
|
+
static #showDialog(event, method = String(), options = Object()) {
|
|
812
|
+
const window = BrowserWindow.fromWebContents(event.sender);
|
|
813
|
+
return window === null ? dialog[method](options) : dialog[method](window, options);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Каналы кнопок окна. Ими пользуются оба режима: безопасное окно — через мост
|
|
818
|
+
* `window.chui.window`, обычное — прямым `ipcRenderer.invoke`. Регистрируются один раз на приложение.
|
|
819
|
+
*/
|
|
820
|
+
#enableWindowActions() {
|
|
821
|
+
if (this.#windowActionsBound) return this;
|
|
822
|
+
this.#windowActionsBound = true;
|
|
823
|
+
this.#handle(BRIDGE_CHANNELS.WINDOW_ACTION, (event, action) => Main.#applyWindowAction(event.sender, action));
|
|
824
|
+
this.#handle(BRIDGE_CHANNELS.WINDOW_STATE, (event) => {
|
|
825
|
+
const window = BrowserWindow.fromWebContents(event.sender);
|
|
826
|
+
if (window === null) return {};
|
|
827
|
+
return {
|
|
828
|
+
isMaximized: window.isMaximized(),
|
|
829
|
+
isMinimized: window.isMinimized(),
|
|
830
|
+
isFullScreen: window.isFullScreen(),
|
|
831
|
+
isFocused: window.isFocused(),
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
return this;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
enableBridge() {
|
|
838
|
+
if (this.#bridgeEnabled) return this;
|
|
839
|
+
this.#bridgeEnabled = true;
|
|
840
|
+
this.#enableWindowActions();
|
|
841
|
+
this.#handle(BRIDGE_CHANNELS.THEME_GET, () => (nativeTheme.shouldUseDarkColors ? "dark" : "light"));
|
|
842
|
+
this.#handle(BRIDGE_CHANNELS.LOCALE_GET, () => this.services.locale.get());
|
|
843
|
+
this.#handle(BRIDGE_CHANNELS.MESSAGE, (event, channel, ...args) => {
|
|
844
|
+
const handler = this.#pageMessageHandlers.get(channel);
|
|
845
|
+
if (handler === undefined) return { handled: false };
|
|
846
|
+
return handler(...args, event);
|
|
847
|
+
});
|
|
848
|
+
this.#bindThemeEvents();
|
|
849
|
+
return this;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Обработчик сообщений из безопасного окна. Канал должен быть указан в
|
|
854
|
+
* `security: {channels: [...]}`, иначе мост его не отправит.
|
|
855
|
+
*
|
|
856
|
+
* ```javascript
|
|
857
|
+
* main.onPageMessage('preview:save', (payload) => console.log(payload));
|
|
858
|
+
* main.sendToPage('preview:update', {ready: true}, 'preview');
|
|
859
|
+
* ```
|
|
860
|
+
*/
|
|
861
|
+
onPageMessage(channel = String(), handler = () => {}) {
|
|
862
|
+
this.enableBridge();
|
|
863
|
+
this.#pageMessageHandlers.set(channel, handler);
|
|
864
|
+
return this;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Сообщение в страницу безопасного окна: его `messaging.on(channel)` получит полезную нагрузку */
|
|
868
|
+
sendToPage(channel = String(), payload = undefined, windowName = undefined) {
|
|
869
|
+
return this.#send(BRIDGE_CHANNELS.MESSAGE, [channel, payload], windowName);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Действия кнопок окна: и обычные окна, и безопасные зовут их по IPC */
|
|
873
|
+
static #applyWindowAction(sender, action = String()) {
|
|
874
|
+
const window = BrowserWindow.fromWebContents(sender);
|
|
875
|
+
if (window === null || window.isDestroyed()) return false;
|
|
876
|
+
if (action === "minimize") window.minimize();
|
|
877
|
+
else if (action === "toggle-maximize") {
|
|
878
|
+
if (window.isMaximized()) window.unmaximize();
|
|
879
|
+
else window.maximize();
|
|
880
|
+
} else if (action === "restore") window.restore();
|
|
881
|
+
else if (action === "toggle-fullscreen") window.setFullScreen(!window.isFullScreen());
|
|
882
|
+
else if (action === "close") window.close();
|
|
883
|
+
else return false;
|
|
884
|
+
return true;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Смена темы: системную ловим событием Electron, выбранную пользователем — сами.
|
|
888
|
+
// Слушатель один на приложение, окна получают событие по своим каналам
|
|
889
|
+
#bindThemeEvents() {
|
|
890
|
+
if (this.#themeBound) return;
|
|
891
|
+
this.#themeBound = true;
|
|
892
|
+
this.#onThemeUpdated = () => this.#emitTheme();
|
|
893
|
+
nativeTheme.on("updated", this.#onThemeUpdated);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Сообщить окнам текущую тему. Обычные окна слушают `Desktop.theme.onChange`,
|
|
898
|
+
* безопасные — мост `window.chui.theme.onChange`.
|
|
899
|
+
*
|
|
900
|
+
* Вызывается из события `nativeTheme` — оно приходит и от системы, и после
|
|
901
|
+
* `theme.set()/toggle()` (Electron сообщает о смене `shouldUseDarkColors`).
|
|
902
|
+
* Поэтому обработчики IPC ничего дополнительно не рассылают: смену источника
|
|
903
|
+
* без смены палитры подписчикам темы сообщать не о чем.
|
|
904
|
+
*/
|
|
905
|
+
#emitTheme() {
|
|
906
|
+
const theme = nativeTheme.shouldUseDarkColors ? "dark" : "light";
|
|
907
|
+
for (const window of this.#windows.values()) {
|
|
908
|
+
if (window.isDestroyed()) continue;
|
|
909
|
+
window.webContents.send(SERVICES_CHANNELS.THEME_EVENT, theme);
|
|
910
|
+
}
|
|
911
|
+
for (const id of this.#secureContents) {
|
|
912
|
+
const contents = webContents.fromId(id);
|
|
913
|
+
if (contents === undefined || contents.isDestroyed()) continue;
|
|
914
|
+
contents.send(BRIDGE_CHANNELS.THEME_EVENT, theme);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Сообщить окнам текущий язык. Обычные окна слушают `Desktop.locale.onChange`,
|
|
920
|
+
* безопасные — мост `window.chui.locale.onChange`.
|
|
921
|
+
*/
|
|
922
|
+
#emitLocale(locale = String()) {
|
|
923
|
+
for (const window of this.#windows.values()) {
|
|
924
|
+
if (window.isDestroyed()) continue;
|
|
925
|
+
window.webContents.send(SERVICES_CHANNELS.LOCALE_EVENT, locale);
|
|
926
|
+
}
|
|
927
|
+
for (const id of this.#secureContents) {
|
|
928
|
+
const contents = webContents.fromId(id);
|
|
929
|
+
if (contents === undefined || contents.isDestroyed()) continue;
|
|
930
|
+
contents.send(BRIDGE_CHANNELS.LOCALE_EVENT, locale);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** История буфера меняется в main — панели в окнах обновляются по этому событию */
|
|
935
|
+
#emitClipboardHistory() {
|
|
936
|
+
const items = this.services.clipboard_history.list();
|
|
937
|
+
for (const window of this.#windows.values()) {
|
|
938
|
+
if (window.isDestroyed()) continue;
|
|
939
|
+
window.webContents.send(SERVICES_CHANNELS.CLIPBOARD_HISTORY_EVENT, items);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Прогресс считаем по окну-отправителю: у каждого окна своя иконка
|
|
944
|
+
#progressFor(event) {
|
|
945
|
+
const id = event.sender.id;
|
|
946
|
+
let progress = this.#progressWindows.get(id);
|
|
947
|
+
if (progress === undefined) {
|
|
948
|
+
progress = new TaskbarProgress(BrowserWindow.fromWebContents(event.sender));
|
|
949
|
+
this.#progressWindows.set(id, progress);
|
|
950
|
+
}
|
|
951
|
+
return progress;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Подписка окна на глобальную клавишу. Клавиша регистрируется один раз на все окна;
|
|
956
|
+
* `false` в ответе означает, что комбинацию занял другой процесс.
|
|
957
|
+
*/
|
|
958
|
+
#subscribeShortcut(event, accelerator = String()) {
|
|
959
|
+
const id = event.sender.id;
|
|
960
|
+
const subscribers = this.#shortcutSubscribers.get(accelerator) ?? new Set();
|
|
961
|
+
const first = subscribers.size === 0;
|
|
962
|
+
if (first && !this.services.shortcuts.register(accelerator, () => this.#emitShortcut(accelerator))) {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
subscribers.add(id);
|
|
966
|
+
this.#shortcutSubscribers.set(accelerator, subscribers);
|
|
967
|
+
const watched = this.#shortcutWatchers.get(id) ?? new Set();
|
|
968
|
+
watched.add(accelerator);
|
|
969
|
+
this.#shortcutWatchers.set(id, watched);
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
#unsubscribeShortcut(event, accelerator = String()) {
|
|
974
|
+
const id = event.sender.id;
|
|
975
|
+
const subscribers = this.#shortcutSubscribers.get(accelerator);
|
|
976
|
+
if (subscribers !== undefined) {
|
|
977
|
+
subscribers.delete(id);
|
|
978
|
+
if (subscribers.size === 0) {
|
|
979
|
+
this.#shortcutSubscribers.delete(accelerator);
|
|
980
|
+
this.services.shortcuts.unregister(accelerator);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const watched = this.#shortcutWatchers.get(id);
|
|
984
|
+
if (watched !== undefined) {
|
|
985
|
+
watched.delete(accelerator);
|
|
986
|
+
if (watched.size === 0) this.#shortcutWatchers.delete(id);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Опции печати из окна: диалог сохранения PDF должен быть модальным к окну-отправителю,
|
|
992
|
+
* поэтому `parent` берём из события, а не из запроса страницы.
|
|
993
|
+
*/
|
|
994
|
+
#printOptions(event, options = Object()) {
|
|
995
|
+
return { ...options, parent: BrowserWindow.fromWebContents(event.sender) };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
#emitShortcut(accelerator = String()) {
|
|
999
|
+
const subscribers = this.#shortcutSubscribers.get(accelerator);
|
|
1000
|
+
if (subscribers === undefined) return;
|
|
1001
|
+
for (const id of [...subscribers]) {
|
|
1002
|
+
const contents = webContents.fromId(id);
|
|
1003
|
+
if (contents === undefined || contents.isDestroyed()) continue;
|
|
1004
|
+
contents.send(SERVICES_CHANNELS.SHORTCUT_EVENT, accelerator);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Окно закрылось: его подписки больше не нужны, а осиротевшие клавиши освобождаем
|
|
1009
|
+
#dropShortcutSubscriptions(webContentsId = Number()) {
|
|
1010
|
+
const watched = this.#shortcutWatchers.get(webContentsId);
|
|
1011
|
+
if (watched === undefined) return;
|
|
1012
|
+
this.#shortcutWatchers.delete(webContentsId);
|
|
1013
|
+
for (const accelerator of watched) {
|
|
1014
|
+
const subscribers = this.#shortcutSubscribers.get(accelerator);
|
|
1015
|
+
if (subscribers === undefined) continue;
|
|
1016
|
+
subscribers.delete(webContentsId);
|
|
1017
|
+
if (subscribers.size !== 0) continue;
|
|
1018
|
+
this.#shortcutSubscribers.delete(accelerator);
|
|
1019
|
+
this.services.shortcuts.unregister(accelerator);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
toggleDevTools(name = Main.MAIN_WINDOW) {
|
|
1024
|
+
const window = this.#windows.get(name);
|
|
1025
|
+
if (window === undefined) return false;
|
|
1026
|
+
if (this.#devToolsOpened && name === Main.MAIN_WINDOW) {
|
|
1027
|
+
window.webContents.closeDevTools();
|
|
1028
|
+
this.#devToolsOpened = false;
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
window.webContents.openDevTools();
|
|
1032
|
+
if (name === Main.MAIN_WINDOW) this.#devToolsOpened = true;
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
#hideAndShow() {
|
|
1037
|
+
const window = this.#targetWindow();
|
|
1038
|
+
if (window === undefined) return;
|
|
1039
|
+
const visible = window.isVisible();
|
|
1040
|
+
const focus = window.isFocused();
|
|
1041
|
+
|
|
1042
|
+
if (visible && focus) {
|
|
1043
|
+
window.hide();
|
|
1044
|
+
} else if (!visible && !focus) {
|
|
1045
|
+
window.show();
|
|
1046
|
+
} else {
|
|
1047
|
+
window.focus();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Окно-адресат: по умолчанию главное, но уведомление можно отправить и в своё окно
|
|
1052
|
+
#targetWindow(name = undefined) {
|
|
1053
|
+
return this.#windows.get(name === undefined ? Main.MAIN_WINDOW : name) ?? this.#window;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
#send(channel = String(), args = [], windowName = undefined) {
|
|
1057
|
+
const window = this.#targetWindow(windowName);
|
|
1058
|
+
if (window === undefined || window.isDestroyed()) return false;
|
|
1059
|
+
window.webContents.send(channel, ...args);
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
stop() {
|
|
1064
|
+
this.#removeGlobalHandlers();
|
|
1065
|
+
if (process.platform !== "darwin") {
|
|
1066
|
+
app.quit();
|
|
1067
|
+
} else {
|
|
1068
|
+
app.exit(0);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
restart() {
|
|
1073
|
+
this.#removeGlobalHandlers();
|
|
1074
|
+
app.relaunch();
|
|
1075
|
+
app.exit(0);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
#removeGlobalHandlers() {
|
|
1079
|
+
ipcMain.removeListener("show_system_notification", this.#onShowSystemNotification);
|
|
1080
|
+
ipcMain.removeListener("SEND_LOG_TEXT", this.#onSendLogText);
|
|
1081
|
+
ipcMain.removeListener("updateInstallConfirm", this.#onUpdateInstallConfirm);
|
|
1082
|
+
// Папка загрузок: подписка на сессию снимается вместе с остальными глобальными обработчиками
|
|
1083
|
+
if (this.#downloadBound) {
|
|
1084
|
+
this.#downloadBound = false;
|
|
1085
|
+
session.defaultSession.removeListener("will-download", this.#onWillDownload);
|
|
1086
|
+
}
|
|
1087
|
+
// Десктоп-сервисы: снимаем обработчики IPC, подписки клавиш и сами клавиши
|
|
1088
|
+
for (const [channel] of this.#serviceHandlers) ipcMain.removeHandler(channel);
|
|
1089
|
+
this.#serviceHandlers = [];
|
|
1090
|
+
for (const [channel, listener] of this.#serviceSyncHandlers) ipcMain.removeListener(channel, listener);
|
|
1091
|
+
this.#serviceSyncHandlers = [];
|
|
1092
|
+
this.#shortcutSubscribers.clear();
|
|
1093
|
+
this.#shortcutWatchers.clear();
|
|
1094
|
+
this.#progressWindows.clear();
|
|
1095
|
+
if (this.services !== undefined) this.services.shortcuts.destroy();
|
|
1096
|
+
this.#servicesEnabled = false;
|
|
1097
|
+
// Мост безопасных окон: обработчики ipcMain уже сняты выше, остались события темы
|
|
1098
|
+
if (this.#themeBound && this.#onThemeUpdated !== undefined) {
|
|
1099
|
+
nativeTheme.removeListener("updated", this.#onThemeUpdated);
|
|
1100
|
+
}
|
|
1101
|
+
this.#themeBound = false;
|
|
1102
|
+
this.#onThemeUpdated = undefined;
|
|
1103
|
+
this.#pageMessageHandlers.clear();
|
|
1104
|
+
this.#bridgeEnabled = false;
|
|
1105
|
+
this.#windowActionsBound = false;
|
|
1106
|
+
if (this.#updaterListening) {
|
|
1107
|
+
this.#updaterListening = false;
|
|
1108
|
+
require("electron-updater").autoUpdater.removeListener("update-downloaded", this.#onUpdateDownloaded);
|
|
1109
|
+
}
|
|
1110
|
+
if (this.#processHandlersRegistered) {
|
|
1111
|
+
this.#processHandlersRegistered = false;
|
|
1112
|
+
process.removeListener("uncaughtException", this.#onUncaughtException);
|
|
1113
|
+
process.removeListener("unhandledRejection", this.#onUnhandledRejection);
|
|
1114
|
+
}
|
|
1115
|
+
this.#ipcRegistered = false;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
start(options = { hideOnClose: Boolean(), globalMenu: [], tray: [], extensions: [] }) {
|
|
1119
|
+
nativeTheme.themeSource = "system";
|
|
1120
|
+
// Обработчики процесса — один раз на экземпляр Main (иначе копились при повторном start())
|
|
1121
|
+
if (this.#ipcRegistered === false) {
|
|
1122
|
+
this.#ipcRegistered = true;
|
|
1123
|
+
ipcMain.on("show_system_notification", this.#onShowSystemNotification);
|
|
1124
|
+
ipcMain.on("SEND_LOG_TEXT", this.#onSendLogText);
|
|
1125
|
+
}
|
|
1126
|
+
// Необработанные ошибки main-процесса — в тот же лог, что и Log.error (консоль + файл)
|
|
1127
|
+
if (this.#processHandlersRegistered === false) {
|
|
1128
|
+
this.#processHandlersRegistered = true;
|
|
1129
|
+
process.on("uncaughtException", this.#onUncaughtException);
|
|
1130
|
+
process.on("unhandledRejection", this.#onUnhandledRejection);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
app.whenReady().then(() => {
|
|
1134
|
+
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
|
1135
|
+
if (permission === "media") {
|
|
1136
|
+
callback(true); // Approve microphone/camera access
|
|
1137
|
+
} else {
|
|
1138
|
+
callback(false);
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
if (options.extensions) {
|
|
1143
|
+
for (const extension of options.extensions)
|
|
1144
|
+
session.defaultSession
|
|
1145
|
+
.loadExtension(extension)
|
|
1146
|
+
.then((r) => Log.info(r))
|
|
1147
|
+
.catch((e) => Log.error(e));
|
|
1148
|
+
}
|
|
1149
|
+
if (options.tray) {
|
|
1150
|
+
this.#tray = new Tray(this.#appIcon);
|
|
1151
|
+
const context = Menu.buildFromTemplate(options.tray);
|
|
1152
|
+
this.#tray.setContextMenu(context);
|
|
1153
|
+
|
|
1154
|
+
this.#tray.on("click", () => this.#hideAndShow());
|
|
1155
|
+
}
|
|
1156
|
+
if (options.globalMenu) {
|
|
1157
|
+
const menu = Menu.buildFromTemplate(options.globalMenu);
|
|
1158
|
+
Menu.setApplicationMenu(menu);
|
|
1159
|
+
} else {
|
|
1160
|
+
Menu.setApplicationMenu(null);
|
|
1161
|
+
}
|
|
1162
|
+
this.enableServices();
|
|
1163
|
+
this.#buildWindow({ name: Main.MAIN_WINDOW, hideOnClose: options.hideOnClose });
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
#writeLog(text) {
|
|
1168
|
+
const today = new Date().toLocaleDateString().replace("/", ".");
|
|
1169
|
+
const logDirPath = path.join(app.getPath("userData"), "logs");
|
|
1170
|
+
const logFilePath = path.join(logDirPath, `app_${today}.log`);
|
|
1171
|
+
|
|
1172
|
+
if (!fs.existsSync(logDirPath)) {
|
|
1173
|
+
fs.mkdirSync(logDirPath, { recursive: true });
|
|
1174
|
+
fs.writeFileSync(logFilePath, "", "utf-8");
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
fs.writeFile(logFilePath, `${text}\n`, { flag: "a+" }, (err) => {
|
|
1178
|
+
if (err) console.error(err);
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
enableAutoUpdateApp(start = Number()) {
|
|
1183
|
+
const { autoUpdater } = require("electron-updater");
|
|
1184
|
+
// Подписки на апдейтер и подтверждение установки — один раз на экземпляр Main:
|
|
1185
|
+
// раньше ipcMain.on("updateInstallConfirm") и autoUpdater.on добавлялись
|
|
1186
|
+
// внутри update-downloaded, то есть на каждое событие.
|
|
1187
|
+
if (this.#updaterListening === false) {
|
|
1188
|
+
this.#updaterListening = true;
|
|
1189
|
+
autoUpdater.on("update-downloaded", this.#onUpdateDownloaded);
|
|
1190
|
+
ipcMain.on("updateInstallConfirm", this.#onUpdateInstallConfirm);
|
|
1191
|
+
}
|
|
1192
|
+
setTimeout(async () => {
|
|
1193
|
+
autoUpdater.autoInstallOnAppQuit = false;
|
|
1194
|
+
const updates = await autoUpdater.checkForUpdates();
|
|
1195
|
+
if (updates !== null) {
|
|
1196
|
+
this.#lastUpdateInfo = updates;
|
|
1197
|
+
if (updates.versionInfo.version > app.getVersion()) {
|
|
1198
|
+
await this.#sendNotificationUpdateLoad(
|
|
1199
|
+
this.#appName,
|
|
1200
|
+
I18n.t("appLayout.update.downloading", { version: updates.versionInfo.version })
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}, start);
|
|
1205
|
+
}
|
|
1206
|
+
async #sendNotificationUpdateLoad(text, body) {
|
|
1207
|
+
this.#send("sendNotificationUpdateLoad", [text, body]);
|
|
1208
|
+
}
|
|
1209
|
+
async #sendNotificationUpdateLoadClose() {
|
|
1210
|
+
this.#send("sendNotificationUpdateLoadClose");
|
|
1211
|
+
}
|
|
1212
|
+
async #sendNotificationUpdate(text, body) {
|
|
1213
|
+
this.#send("sendNotificationUpdate", [text, body]);
|
|
1214
|
+
}
|
|
1215
|
+
async #sendNotificationUpdateClose() {
|
|
1216
|
+
this.#send("sendNotificationUpdateClose");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Уведомление о загрузке; последним аргументом можно указать окно реестра */
|
|
1220
|
+
sendDownload(text, body, windowName = undefined) {
|
|
1221
|
+
setTimeout(() => this.#send("sendNotificationDownload", [text, body], windowName), 1);
|
|
1222
|
+
}
|
|
1223
|
+
sendDownloadUpdate(text, body, windowName = undefined) {
|
|
1224
|
+
setTimeout(() => this.#send("sendNotificationDownloadUpdate", [text, body], windowName), 1);
|
|
1225
|
+
}
|
|
1226
|
+
sendDownloadComplete(text, body, windowName = undefined) {
|
|
1227
|
+
setTimeout(() => this.#send("sendNotificationDownloadComplete", [text, body], windowName), 250);
|
|
1228
|
+
}
|
|
1229
|
+
sendDownloadError(text, body, windowName = undefined) {
|
|
1230
|
+
setTimeout(() => this.#send("sendNotificationDownloadError", [text, body], windowName), 250);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
exports.Main = Main;
|