From 98da83374c76428da12d0a72f4a2e8627a7c92b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 21 Jul 2026 14:55:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=86=8D=EC=84=B1=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20=EC=B0=BD=20=EB=8F=84=EC=9E=85,=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=C2=B7?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?authority=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/capabilities/dmnote-dev.json | 4 +- src-tauri/capabilities/main.json | 2 +- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/permissions/dmnote-allow-all.json | 25 +- src-tauri/src/commands/app/system.rs | 14 +- src-tauri/src/commands/editor/css.rs | 110 +- src-tauri/src/commands/editor/history.rs | 428 + src-tauri/src/commands/editor/js.rs | 173 +- src-tauri/src/commands/editor/mod.rs | 3 + src-tauri/src/commands/editor/note_tab.rs | 23 +- src-tauri/src/commands/editor/preview.rs | 31 + src-tauri/src/commands/editor/selection.rs | 19 + src-tauri/src/commands/editor/state.rs | 178 +- src-tauri/src/commands/keys/keys.rs | 384 +- src-tauri/src/commands/layout/mod.rs | 1 + src-tauri/src/commands/layout/panel.rs | 206 + src-tauri/src/commands/layout/settings.rs | 18 +- src-tauri/src/commands/plugin/instances.rs | 310 + src-tauri/src/commands/plugin/mod.rs | 2 + src-tauri/src/commands/plugin/rpc.rs | 115 + src-tauri/src/commands/plugin/storage.rs | 138 +- src-tauri/src/commands/preset/load.rs | 91 +- src-tauri/src/commands/preset/mod.rs | 3 +- src-tauri/src/errors.rs | 25 + src-tauri/src/keyboard/manager.rs | 12 + src-tauri/src/lib.rs | 27 + src-tauri/src/main.rs | 90 +- src-tauri/src/models/editor.rs | 48 +- src-tauri/src/models/mod.rs | 13 + src-tauri/src/models/plugin.rs | 143 + src-tauri/src/services/css_watcher.rs | 56 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/obs_bridge.rs | 5 + src-tauri/src/services/preview_broker.rs | 900 +++ src-tauri/src/state/app_state.rs | 2772 ++++++- src-tauri/src/state/editor.rs | 59 +- src-tauri/src/state/history.rs | 1182 +++ src-tauri/src/state/macos_termination.rs | 159 + src-tauri/src/state/migration.rs | 60 + src-tauri/src/state/mod.rs | 7 +- src-tauri/src/state/plugin.rs | 743 ++ src-tauri/src/state/store.rs | 7031 +++++++++++++---- .../__tests__/batchShadowUpdate.test.ts | 13 +- .../batchStyleActiveAggregation.test.tsx | 69 +- .../gridCanvasDeletionSelection.test.ts | 6 - .../__tests__/keyDeletionSelection.test.tsx | 167 - .../__tests__/presetLoadSelection.test.tsx | 16 - .../__tests__/previewOverlaySession.test.ts | 459 ++ src/renderer/api/modules/appApi.test.ts | 91 +- src/renderer/api/modules/appApi.ts | 19 +- src/renderer/api/modules/bridgeApi.ts | 19 +- .../api/modules/editorAdapters.test.ts | 11 +- src/renderer/api/modules/editorApi.ts | 15 - src/renderer/api/modules/historyApi.ts | 26 + src/renderer/api/modules/keysApi.test.ts | 31 + src/renderer/api/modules/keysApi.ts | 44 +- .../api/modules/pluginInstancesApi.test.ts | 41 + .../api/modules/pluginInstancesApi.ts | 70 + src/renderer/api/modules/pluginRpcApi.ts | 59 + src/renderer/api/modules/previewApi.ts | 31 + .../api/modules/selectionSessionApi.ts | 92 + ...PropertiesPanel.detachedContracts.test.tsx | 580 ++ .../components/main/Grid/PropertiesPanel.tsx | 593 +- .../PanelHeaderActions.test.tsx | 132 + .../PropertiesPanel/PanelHeaderActions.tsx | 138 +- .../PropertiesPanel/PanelToggleButton.tsx | 6 +- .../PluginSettingsPanelView.tsx | 6 +- .../PropertiesPanel/PropertyInputs.test.tsx | 214 + .../Grid/PropertiesPanel/PropertyInputs.tsx | 189 +- .../batch/BatchSelectionPanel.tsx | 12 +- .../batch/BatchStyleTabContent.tsx | 29 +- .../batch/useBatchHandlers.test.ts | 51 +- .../PropertiesPanel/batch/useBatchHandlers.ts | 41 +- .../PropertiesPanel/layer/LayerTabContent.tsx | 7 +- .../PropertiesPanel/layer/layerPanelModel.ts | 4 +- .../PropertiesPanel/layer/useLayerActions.ts | 112 +- .../Grid/PropertiesPanel/layer/useLayerDnD.ts | 65 +- .../main/Grid/PropertiesPanel/panelChrome.ts | 7 +- .../PropertiesPanel/single/NoteTabContent.tsx | 21 + .../single/SingleSelectionPanel.tsx | 12 +- .../single/StyleTabContent.tsx | 34 +- .../main/Grid/PropertiesPanel/types.ts | 14 +- .../components/main/Grid/core/Grid.tsx | 121 +- .../main/Grid/core/GridKeySettingModal.tsx | 46 +- .../main/Modal/FullSurfaceModalLayout.tsx | 9 +- .../main/Modal/content/dialogs/Alert.tsx | 9 +- .../content/dialogs/UnifiedKeySetting.tsx | 8 +- .../Modal/content/editors/TabNameModal.tsx | 9 +- .../Modal/content/settings/KeyTabContent.tsx | 2 + .../Modal/content/settings/NoteSetting.tsx | 9 +- src/renderer/components/main/Settings.tsx | 1 + .../SettingsPanel/ShortcutsPanelContent.tsx | 2 + .../components/main/Tool/SettingTool.tsx | 72 - .../components/shared/PluginElement.tsx | 67 +- .../editor/runtime/editGestureController.ts | 318 + .../runtime/editorCompatibilityQueue.ts | 4 +- .../editor/runtime/editorCoordinator.ts | 53 +- src/renderer/editor/runtime/editorSnapshot.ts | 231 - .../editor/runtime/editorStateCoordinator.ts | 27 +- .../editor/runtime/editorWriteBarrier.test.ts | 94 + .../editor/runtime/editorWriteBarrier.ts | 39 + .../runtime/historyEditorFlushLock.test.ts | 68 + .../editor/runtime/historyEditorFlushLock.ts | 79 + src/renderer/editor/runtime/index.ts | 8 - .../editor/runtime/lifecycleEditorDraft.ts | 29 + .../runtime/lifecycleEditorFlush.test.ts | 70 + .../editor/runtime/lifecycleEditorFlush.ts | 26 + src/renderer/editor/runtime/persistState.ts | 29 +- src/renderer/editor/runtime/previewOverlay.ts | 210 + .../editor/runtime/selectionSync.test.ts | 210 + src/renderer/editor/runtime/selectionSync.ts | 231 + .../hooks/Grid/useGridCanvasActions.ts | 55 +- src/renderer/hooks/Grid/useGridKeyboard.ts | 55 +- src/renderer/hooks/Grid/useGridResize.ts | 23 +- src/renderer/hooks/Grid/useGridSelection.ts | 76 +- .../hooks/Grid/useHistoryShortcuts.test.tsx | 92 + .../hooks/Grid/useHistoryShortcuts.ts | 57 + src/renderer/hooks/app/useAppBootstrap.ts | 183 +- .../app/useBlockBrowserShortcuts.test.tsx | 88 + .../hooks/app/useBlockBrowserShortcuts.ts | 12 +- .../app/usePluginDisplayElementsResponder.ts | 17 + .../hooks/app/usePluginPanelModelMirror.ts | 68 + src/renderer/hooks/useKeyManager.ts | 624 +- src/renderer/hooks/usePressAction.test.tsx | 262 + src/renderer/hooks/usePressAction.ts | 173 + src/renderer/locales/en.json | 3 + src/renderer/locales/ko.json | 3 + src/renderer/locales/ru.json | 3 + src/renderer/locales/zh-Hant.json | 3 + src/renderer/locales/zh-cn.json | 3 + .../plugins/rpc/pluginElementActions.test.ts | 97 + .../plugins/rpc/pluginElementActions.ts | 270 + .../plugins/rpc/pluginModelRevision.ts | 16 + src/renderer/plugins/rpc/pluginRpcClient.ts | 127 + src/renderer/plugins/rpc/pluginRpcHandler.ts | 389 + .../plugins/rpc/pluginSettingsMirror.test.ts | 313 + .../plugins/rpc/pluginSettingsMirror.ts | 396 + .../plugins/rpc/pluginSettingsSession.ts | 361 + .../plugins/rpc/settingsSessionWire.ts | 51 + .../plugins/runtime/api/defineElement.test.ts | 85 + .../plugins/runtime/api/defineElement.ts | 464 +- .../plugins/runtime/api/defineSettings.ts | 12 +- .../plugins/runtime/api/pluginApiProxy.ts | 4 +- .../plugins/runtime/customJsRuntime.ts | 86 +- .../displayElement/displayElementApi.ts | 7 - .../runtime/displayElement/historyUtils.ts | 27 - .../plugins/runtime/displayElement/index.ts | 1 - .../displayElement/instanceLifecycle.test.ts | 88 +- .../displayElement/instanceLifecycle.ts | 51 +- .../instancesCommitQueue.test.ts | 167 + .../displayElement/instancesCommitQueue.ts | 114 + .../displayElement/instancesUndoSync.test.ts | 46 + .../displayElement/instancesUndoSync.ts | 120 + src/renderer/plugins/runtime/index.ts | 1 - .../stores/data/useHistoryStatusStore.ts | 47 + .../stores/data/useHistoryStore.test.ts | 349 - src/renderer/stores/data/useHistoryStore.ts | 242 - src/renderer/stores/data/useKeyStore.ts | 30 +- .../stores/grid/panelViewHandoff.test.ts | 66 + src/renderer/stores/grid/panelViewHandoff.ts | 41 + .../stores/grid/usePanelWindowStore.test.ts | 269 + .../stores/grid/usePanelWindowStore.ts | 123 + .../stores/grid/usePropertiesPanelStore.ts | 8 + ...uginDisplayElementStore.panelModel.test.ts | 75 + .../plugin/usePluginDisplayElementStore.ts | 56 +- src/renderer/utils/grid/groupActions.ts | 29 +- .../utils/plugin/panelModelSync.test.ts | 62 + src/renderer/utils/plugin/panelModelSync.ts | 193 + .../plugin/pluginElementMeasurement.test.ts | 71 + .../utils/plugin/pluginElementMeasurement.ts | 46 + src/renderer/windows/main/App.tsx | 40 +- src/renderer/windows/panel/App.test.tsx | 243 + src/renderer/windows/panel/App.tsx | 188 + .../windows/panel/PanelDialogHost.tsx | 107 + src/renderer/windows/panel/index.html | 11 + src/renderer/windows/panel/index.tsx | 47 + src/types/editor.ts | 26 +- src/types/electron.d.ts | 2 +- src/types/plugin/api.ts | 83 +- src/types/preview.ts | 36 + vite.config.ts | 1 + 184 files changed, 23478 insertions(+), 5350 deletions(-) create mode 100644 src-tauri/src/commands/editor/history.rs create mode 100644 src-tauri/src/commands/editor/preview.rs create mode 100644 src-tauri/src/commands/editor/selection.rs create mode 100644 src-tauri/src/commands/layout/panel.rs create mode 100644 src-tauri/src/commands/plugin/instances.rs create mode 100644 src-tauri/src/commands/plugin/rpc.rs create mode 100644 src-tauri/src/models/plugin.rs create mode 100644 src-tauri/src/services/preview_broker.rs create mode 100644 src-tauri/src/state/history.rs create mode 100644 src-tauri/src/state/macos_termination.rs create mode 100644 src-tauri/src/state/plugin.rs create mode 100644 src/renderer/__tests__/previewOverlaySession.test.ts create mode 100644 src/renderer/api/modules/historyApi.ts create mode 100644 src/renderer/api/modules/keysApi.test.ts create mode 100644 src/renderer/api/modules/pluginInstancesApi.test.ts create mode 100644 src/renderer/api/modules/pluginInstancesApi.ts create mode 100644 src/renderer/api/modules/pluginRpcApi.ts create mode 100644 src/renderer/api/modules/previewApi.ts create mode 100644 src/renderer/api/modules/selectionSessionApi.ts create mode 100644 src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PanelHeaderActions.test.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.test.tsx create mode 100644 src/renderer/editor/runtime/editGestureController.ts delete mode 100644 src/renderer/editor/runtime/editorSnapshot.ts create mode 100644 src/renderer/editor/runtime/editorWriteBarrier.test.ts create mode 100644 src/renderer/editor/runtime/editorWriteBarrier.ts create mode 100644 src/renderer/editor/runtime/historyEditorFlushLock.test.ts create mode 100644 src/renderer/editor/runtime/historyEditorFlushLock.ts create mode 100644 src/renderer/editor/runtime/lifecycleEditorDraft.ts create mode 100644 src/renderer/editor/runtime/lifecycleEditorFlush.test.ts create mode 100644 src/renderer/editor/runtime/lifecycleEditorFlush.ts create mode 100644 src/renderer/editor/runtime/previewOverlay.ts create mode 100644 src/renderer/editor/runtime/selectionSync.test.ts create mode 100644 src/renderer/editor/runtime/selectionSync.ts create mode 100644 src/renderer/hooks/Grid/useHistoryShortcuts.test.tsx create mode 100644 src/renderer/hooks/Grid/useHistoryShortcuts.ts create mode 100644 src/renderer/hooks/app/useBlockBrowserShortcuts.test.tsx create mode 100644 src/renderer/hooks/app/usePluginPanelModelMirror.ts create mode 100644 src/renderer/hooks/usePressAction.test.tsx create mode 100644 src/renderer/hooks/usePressAction.ts create mode 100644 src/renderer/plugins/rpc/pluginElementActions.test.ts create mode 100644 src/renderer/plugins/rpc/pluginElementActions.ts create mode 100644 src/renderer/plugins/rpc/pluginModelRevision.ts create mode 100644 src/renderer/plugins/rpc/pluginRpcClient.ts create mode 100644 src/renderer/plugins/rpc/pluginRpcHandler.ts create mode 100644 src/renderer/plugins/rpc/pluginSettingsMirror.test.ts create mode 100644 src/renderer/plugins/rpc/pluginSettingsMirror.ts create mode 100644 src/renderer/plugins/rpc/pluginSettingsSession.ts create mode 100644 src/renderer/plugins/rpc/settingsSessionWire.ts create mode 100644 src/renderer/plugins/runtime/api/defineElement.test.ts create mode 100644 src/renderer/plugins/runtime/displayElement/instancesCommitQueue.test.ts create mode 100644 src/renderer/plugins/runtime/displayElement/instancesCommitQueue.ts create mode 100644 src/renderer/plugins/runtime/displayElement/instancesUndoSync.test.ts create mode 100644 src/renderer/plugins/runtime/displayElement/instancesUndoSync.ts create mode 100644 src/renderer/stores/data/useHistoryStatusStore.ts delete mode 100644 src/renderer/stores/data/useHistoryStore.test.ts delete mode 100644 src/renderer/stores/data/useHistoryStore.ts create mode 100644 src/renderer/stores/grid/panelViewHandoff.test.ts create mode 100644 src/renderer/stores/grid/panelViewHandoff.ts create mode 100644 src/renderer/stores/grid/usePanelWindowStore.test.ts create mode 100644 src/renderer/stores/grid/usePanelWindowStore.ts create mode 100644 src/renderer/stores/plugin/usePluginDisplayElementStore.panelModel.test.ts create mode 100644 src/renderer/utils/plugin/panelModelSync.test.ts create mode 100644 src/renderer/utils/plugin/panelModelSync.ts create mode 100644 src/renderer/utils/plugin/pluginElementMeasurement.test.ts create mode 100644 src/renderer/utils/plugin/pluginElementMeasurement.ts create mode 100644 src/renderer/windows/panel/App.test.tsx create mode 100644 src/renderer/windows/panel/App.tsx create mode 100644 src/renderer/windows/panel/PanelDialogHost.tsx create mode 100644 src/renderer/windows/panel/index.html create mode 100644 src/renderer/windows/panel/index.tsx create mode 100644 src/types/preview.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d291c359..5a85b3ca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1157,6 +1157,7 @@ version = "1.6.1" dependencies = [ "anyhow", "base64 0.22.1", + "cocoa", "cpal", "dirs-next", "fern", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c2500583..221a767d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -77,6 +77,7 @@ libc = "0.2" rdev = "0.5.3" objc = "0.2" objc-foundation = "0.1" +cocoa = "0.22" thread-priority = "1.2" # objc 크레이트의 매크로에서 발생하는 cfg 경고 억제 diff --git a/src-tauri/capabilities/dmnote-dev.json b/src-tauri/capabilities/dmnote-dev.json index 3a08a940..87765094 100644 --- a/src-tauri/capabilities/dmnote-dev.json +++ b/src-tauri/capabilities/dmnote-dev.json @@ -2,8 +2,8 @@ "identifier": "dmnote-dev", "description": "Dev server capability (remote URLs are added at runtime by register_dev_capability)", "local": true, - "windows": ["main", "overlay"], - "webviews": ["main", "overlay"], + "windows": ["main", "overlay", "panel"], + "webviews": ["main", "overlay", "panel"], "permissions": [ "dmnote-allow-all", "core:default", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index de6842cf..b17156f4 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -2,7 +2,7 @@ "identifier": "main", "description": "Main window capability granting DM Note access", "local": true, - "windows": ["main", "overlay"], + "windows": ["main", "overlay", "panel"], "permissions": [ "core:default", "core:window:allow-start-dragging", diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 8bbcbf56..85c000df 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_history_restore","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","keys_update_with_positions","knob_positions_get","knob_positions_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_preview_cancel","editor_preview_publish","editor_preview_subscribe","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","history_redo","history_status","history_undo","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","knob_positions_get","knob_positions_update","layer_groups_get","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","panel_window_close","panel_window_close_ack","panel_window_is_open","panel_window_show","panel_window_start_dragging","panel_window_take_view_state","plugin_authority_reset","plugin_bridge_send","plugin_bridge_send_to","plugin_instances_commit","plugin_instances_get","plugin_instances_reconcile","plugin_rpc_respond","plugin_rpc_send","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","selection_session_get","selection_session_publish","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index a9b68347..3f9af7a7 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"dmnote-dev":{"identifier":"dmnote-dev","description":"Dev server capability (remote URLs are added at runtime by register_dev_capability)","local":true,"windows":["main","overlay"],"webviews":["main","overlay"],"permissions":["dmnote-allow-all","core:default","core:window:allow-start-dragging"]},"main":{"identifier":"main","description":"Main window capability granting DM Note access","local":true,"windows":["main","overlay"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-current-monitor","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-set-position","dmnote-allow-all"]}} \ No newline at end of file +{"dmnote-dev":{"identifier":"dmnote-dev","description":"Dev server capability (remote URLs are added at runtime by register_dev_capability)","local":true,"windows":["main","overlay","panel"],"webviews":["main","overlay","panel"],"permissions":["dmnote-allow-all","core:default","core:window:allow-start-dragging"]},"main":{"identifier":"main","description":"Main window capability granting DM Note access","local":true,"windows":["main","overlay","panel"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-current-monitor","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-set-position","dmnote-allow-all"]}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 6c70500e..85830e11 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -41,11 +41,16 @@ "custom_tabs_select", "editor_commit", "editor_get", - "editor_history_restore", + "editor_preview_cancel", + "editor_preview_publish", + "editor_preview_subscribe", "font_load", "get_cursor_settings", "graph_positions_get", "graph_positions_update", + "history_redo", + "history_status", + "history_undo", "image_load", "js_get", "js_get_use", @@ -74,12 +79,9 @@ "keys_reset_single_counter", "keys_set_counters", "keys_set_mode", - "keys_update", - "keys_update_with_positions", "knob_positions_get", "knob_positions_update", "layer_groups_get", - "layer_groups_update", "note_tab_clear", "note_tab_get", "note_tab_get_all", @@ -93,8 +95,20 @@ "overlay_set_anchor", "overlay_set_lock", "overlay_set_visible", + "panel_window_close", + "panel_window_close_ack", + "panel_window_is_open", + "panel_window_show", + "panel_window_start_dragging", + "panel_window_take_view_state", + "plugin_authority_reset", "plugin_bridge_send", "plugin_bridge_send_to", + "plugin_instances_commit", + "plugin_instances_get", + "plugin_instances_reconcile", + "plugin_rpc_respond", + "plugin_rpc_send", "plugin_storage_clear", "plugin_storage_clear_by_prefix", "plugin_storage_get", @@ -103,13 +117,14 @@ "plugin_storage_remove", "plugin_storage_set", "positions_get", - "positions_update", "preset_load", "preset_load_tab", "preset_save", "preset_save_tab", "raw_input_subscribe", "raw_input_unsubscribe", + "selection_session_get", + "selection_session_publish", "settings_get", "settings_update", "sound_delete", diff --git a/src-tauri/src/commands/app/system.rs b/src-tauri/src/commands/app/system.rs index 6fc08523..5bf0ff6e 100644 --- a/src-tauri/src/commands/app/system.rs +++ b/src-tauri/src/commands/app/system.rs @@ -2,7 +2,7 @@ use tauri::{AppHandle, Manager, State, WebviewWindow}; use crate::cursor::{get_macos_cursor_settings, rgb_to_hex}; use crate::errors::CmdResult; -use crate::state::AppState; +use crate::state::{AppState, PANEL_LABEL}; #[tauri::command] pub fn window_minimize(app: AppHandle) -> CmdResult<()> { @@ -58,8 +58,12 @@ pub fn app_quit_after_editor_flush( } #[tauri::command] -pub fn app_cancel_editor_flush(state: State<'_, AppState>, handshake_id: String) -> CmdResult<()> { - state.cancel_frontend_lifecycle(&handshake_id); +pub fn app_cancel_editor_flush( + app: AppHandle, + state: State<'_, AppState>, + handshake_id: String, +) -> CmdResult<()> { + state.cancel_frontend_lifecycle(app, &handshake_id); Ok(()) } @@ -73,6 +77,10 @@ pub fn window_open_devtools_all(app: AppHandle) -> CmdResult<()> { overlay.open_devtools(); let _ = overlay.show(); } + if let Some(panel) = app.get_webview_window(PANEL_LABEL) { + panel.open_devtools(); + let _ = panel.show(); + } Ok(()) } diff --git a/src-tauri/src/commands/editor/css.rs b/src-tauri/src/commands/editor/css.rs index fd770087..f8732726 100644 --- a/src-tauri/src/commands/editor/css.rs +++ b/src-tauri/src/commands/editor/css.rs @@ -18,7 +18,7 @@ use crate::{ defaults::default_keys, errors::CmdResult, models::{AppStoreData, CustomCss, CustomCssHistoryEntry, TabCss, TabCssOverrides}, - state::{atomic_file::atomic_replace, AppState}, + state::{atomic_file::atomic_replace, store::AdmittedHistoryOverlapMutation, AppState}, }; /// OBS 브릿지에 CSS 설정 변경을 settings_diff로 전달 (전체 스냅샷 브로드캐스트 방지) @@ -27,6 +27,12 @@ fn notify_obs_css(state: &AppState) { state.notify_obs_settings_diff(custom_css_settings_diff(&snap)); } +fn emit_history_status(app: &AppHandle, transaction: &AdmittedHistoryOverlapMutation) { + if let Some(status) = transaction.history_status.as_ref() { + emit_best_effort(app, "history:status", status); + } +} + #[derive(Serialize)] pub struct CssToggleResponse { pub enabled: bool, @@ -125,13 +131,13 @@ fn commit_loaded_css( state: &AppState, loaded: &ValidatedCssFile, touch_history: bool, -) -> CmdResult<(CustomCss, bool)> { +) -> CmdResult> { let css = CustomCss { path: Some(loaded.canonical_path.clone()), content: loaded.content.clone(), }; let timestamp = current_unix_millis(); - let updated = state.store.update(|store| { + Ok(state.store.commit_history_overlap_mutation(|store| { store.custom_css = css.clone(); if touch_history { record_custom_css_load( @@ -146,8 +152,8 @@ fn commit_loaded_css( timestamp, ); } - })?; - Ok((css, updated.use_custom_css)) + Ok((css, store.use_custom_css)) + })?) } // ========== 탭별 CSS 응답 타입 ========== @@ -241,10 +247,11 @@ pub fn css_toggle( enabled: bool, ) -> CmdResult { let _operation_guard = state.lock_css_operation(); - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { store.use_custom_css = enabled; + Ok(store.custom_css.clone()) })?; - let css = state.store.snapshot().custom_css; + let css = &transaction.value; if enabled { state.unwatch_global_css(); if let Some(path) = &css.path { @@ -256,9 +263,10 @@ pub fn css_toggle( state.unwatch_global_css(); } + emit_history_status(&app, &transaction); emit_best_effort(&app, "css:use", &CssToggleResponse { enabled }); if enabled { - emit_best_effort(&app, "css:content", &css); + emit_best_effort(&app, "css:content", css); } notify_obs_css(&state); Ok(CssToggleResponse { enabled }) @@ -267,12 +275,14 @@ pub fn css_toggle( #[tauri::command] pub fn css_reset(state: State<'_, AppState>, app: AppHandle) -> CmdResult<()> { let _operation_guard = state.lock_css_operation(); - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { store.use_custom_css = false; store.custom_css = CustomCss::default(); + Ok(()) })?; state.unwatch_global_css(); + emit_history_status(&app, &transaction); emit_best_effort(&app, "css:use", &CssToggleResponse { enabled: false }); emit_best_effort(&app, "css:content", &CustomCss::default()); @@ -293,14 +303,13 @@ pub fn css_set_content( error: Some(CssHistoryErrorCode::TooLarge.as_str().to_string()), }); } - let mut current = state.store.snapshot().custom_css; - current.content = content; - - state.store.update(|store| { - store.custom_css = current.clone(); + let transaction = state.store.commit_history_overlap_mutation(|store| { + store.custom_css.content = content; + Ok(store.custom_css.clone()) })?; - emit_best_effort(&app, "css:content", ¤t); + emit_history_status(&app, &transaction); + emit_best_effort(&app, "css:content", &transaction.value); notify_obs_css(&state); Ok(CssSetContentResponse { @@ -336,15 +345,17 @@ pub fn css_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult CmdResult> { let operation_guard = state.lock_css_operation(); - let updated = state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { store .custom_css_history .retain(|entry| !history_paths_match(&entry.path, &path)); normalize_custom_css_history(&mut store.custom_css_history); + Ok(store.custom_css_history.clone()) })?; drop(operation_guard); - Ok(history_items(&updated.custom_css_history)) + Ok(history_items(&transaction.value)) } // ========== 탭별 CSS 커맨드 ========== @@ -492,14 +506,16 @@ pub fn css_tab_load( content: loaded.content, enabled: true, }; - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { replace_tab_css_override(store, &tab_id, Some(tab_css.clone())); + Ok(()) })?; state.authorize_css_path(&loaded.canonical_path); state.unwatch_tab_css(&tab_id); if let Err(error) = state.watch_tab_css(&loaded.canonical_path, &tab_id) { log::warn!("[css_tab_load] Failed to watch tab {tab_id}: {error}"); } + emit_history_status(&app, &transaction); emit_best_effort( &app, "tabCss:changed", @@ -525,11 +541,13 @@ pub fn css_tab_clear( tab_id: String, ) -> CmdResult { let _operation_guard = state.lock_css_operation(); - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { replace_tab_css_override(store, &tab_id, None); + Ok(()) })?; state.unwatch_tab_css(&tab_id); + emit_history_status(&app, &transaction); emit_best_effort( &app, "tabCss:changed", @@ -555,8 +573,9 @@ pub fn css_tab_set( ) -> CmdResult { let _operation_guard = state.lock_css_operation(); let css = css.map(|tab_css| prepare_tab_css_for_set(&state, tab_css)); - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { replace_tab_css_override(store, &tab_id, css.clone()); + Ok(()) })?; state.unwatch_tab_css(&tab_id); if let Some(path) = css @@ -569,6 +588,7 @@ pub fn css_tab_set( } } + emit_history_status(&app, &transaction); emit_best_effort( &app, "tabCss:changed", @@ -594,12 +614,10 @@ pub fn css_tab_toggle( enabled: bool, ) -> CmdResult { let _operation_guard = state.lock_css_operation(); - let mut updated_css: Option = None; - - state.store.update(|store| { - if let Some(tab_css) = store.tab_css_overrides.get_mut(&tab_id) { + let transaction = state.store.commit_history_overlap_mutation(|store| { + let updated_css = if let Some(tab_css) = store.tab_css_overrides.get_mut(&tab_id) { tab_css.enabled = enabled; - updated_css = Some(tab_css.clone()); + tab_css.clone() } else { // 탭 CSS가 없으면 기본 설정으로 생성 let new_css = TabCss { @@ -610,31 +628,31 @@ pub fn css_tab_toggle( store .tab_css_overrides .insert(tab_id.clone(), new_css.clone()); - updated_css = Some(new_css); - } + new_css + }; + Ok(updated_css) })?; state.unwatch_tab_css(&tab_id); - if let Some(ref css) = updated_css { - if enabled { - if let Some(path) = &css.path { - if let Err(err) = state.watch_tab_css(path, &tab_id) { - log::warn!( - "[css_tab_toggle] Failed to start watching tab {}: {}", - tab_id, - err - ); - } + if enabled { + if let Some(path) = &transaction.value.path { + if let Err(err) = state.watch_tab_css(path, &tab_id) { + log::warn!( + "[css_tab_toggle] Failed to start watching tab {}: {}", + tab_id, + err + ); } } } + emit_history_status(&app, &transaction); emit_best_effort( &app, "tabCss:changed", &TabCssResponse { tab_id: tab_id.clone(), - css: updated_css, + css: Some(transaction.value.clone()), }, ); @@ -750,19 +768,21 @@ pub fn css_tab_activate_history( enabled: true, }; let timestamp = current_unix_millis(); - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { replace_tab_css_override(store, &tab_id, Some(tab_css.clone())); touch_custom_css_history( &mut store.custom_css_history, &loaded.canonical_path, timestamp, ); + Ok(()) })?; state.authorize_css_path(&loaded.canonical_path); state.unwatch_tab_css(&tab_id); if let Err(error) = state.watch_tab_css(&loaded.canonical_path, &tab_id) { log::warn!("[css_tab_activate_history] Failed to watch tab {tab_id}: {error}"); } + emit_history_status(&app, &transaction); emit_best_effort( &app, "tabCss:changed", diff --git a/src-tauri/src/commands/editor/history.rs b/src-tauri/src/commands/editor/history.rs new file mode 100644 index 00000000..c255f9a2 --- /dev/null +++ b/src-tauri/src/commands/editor/history.rs @@ -0,0 +1,428 @@ +use tauri::{AppHandle, State}; + +use crate::{ + commands::preset::PresetSnapshot, + commands::{ + keys::keys::CustomTabChangePayload, plugin::instances::publish_plugin_instances_changed, + }, + models::{AppStoreData, CustomCss, CustomJs, HistoryStatus, PluginInstancesChangedPayload}, + services::preview_broker::PreviewBroker, + state::{ + history::{HistoryBarrierLease, HistoryDirection, PresetFullHistorySnapshot}, + store::HistoryAuxChange, + AppState, + }, +}; + +use super::state::{emit_best_effort, publish_history_editor_change}; + +#[tauri::command] +pub fn history_status(state: State<'_, AppState>) -> HistoryStatus { + state.store.history_status() +} + +#[tauri::command] +pub async fn history_undo( + state: State<'_, AppState>, + broker: State<'_, PreviewBroker>, + app: AppHandle, + operation_id: String, +) -> Result { + validate_history_operation_id(&operation_id)?; + let flush = state.request_frontend_history_flush(app.clone(), &operation_id)?; + let mut flush = flush + .await + .map_err(|_| "HISTORY_FRONTEND_FLUSH_DROPPED".to_string())??; + let result = run_history_operation( + state.inner(), + broker.inner(), + &app, + &operation_id, + HistoryDirection::Undo, + flush.take_barrier(), + ); + drop(flush); + result +} + +#[tauri::command] +pub async fn history_redo( + state: State<'_, AppState>, + broker: State<'_, PreviewBroker>, + app: AppHandle, + operation_id: String, +) -> Result { + validate_history_operation_id(&operation_id)?; + let flush = state.request_frontend_history_flush(app.clone(), &operation_id)?; + let mut flush = flush + .await + .map_err(|_| "HISTORY_FRONTEND_FLUSH_DROPPED".to_string())??; + let result = run_history_operation( + state.inner(), + broker.inner(), + &app, + &operation_id, + HistoryDirection::Redo, + flush.take_barrier(), + ); + drop(flush); + result +} + +fn validate_history_operation_id(operation_id: &str) -> Result<(), String> { + if operation_id.len() > 64 || uuid::Uuid::parse_str(operation_id).is_err() { + return Err("INVALID_HISTORY_OPERATION_ID".to_string()); + } + Ok(()) +} + +fn run_history_operation( + state: &AppState, + broker: &PreviewBroker, + app: &AppHandle, + operation_id: &str, + direction: HistoryDirection, + barrier: HistoryBarrierLease, +) -> Result { + let busy_status = state.store.history_status(); + emit_best_effort(app, "history:status", &busy_status); + state.begin_counter_history_barrier(); + let mut counter_guard = state.lock_key_counters_for_history(); + let current_key_counters = counter_guard.clone(); + let previous_store = state.store.snapshot(); + let result = + state + .store + .apply_history_operation(direction, operation_id, ¤t_key_counters, || { + broker.cancel_all(); + }); + let committed_change = result + .as_ref() + .ok() + .filter(|outcome| !outcome.replayed) + .and_then(|outcome| outcome.change.as_ref()); + let mut counters_restored = false; + if let Ok(outcome) = &result { + match outcome.aux_change.as_ref() { + Some(HistoryAuxChange::CustomTabs { snapshot, .. }) => { + state.apply_committed_editor_keys_without_counters( + outcome.runtime_publication_generation, + &snapshot.document.keys, + &snapshot.selected_key_type, + ); + counters_restored = state.replace_history_counters_locked( + &mut counter_guard, + outcome.runtime_publication_generation, + &snapshot.key_counters, + ); + } + Some(HistoryAuxChange::PresetFull { snapshot, .. }) => { + state.apply_committed_editor_keys_without_counters( + outcome.runtime_publication_generation, + &snapshot.document.keys, + &snapshot.selected_key_type, + ); + counters_restored = state.replace_history_counters_locked( + &mut counter_guard, + outcome.runtime_publication_generation, + &snapshot.key_counters, + ); + } + Some(HistoryAuxChange::Mode(mode)) => { + let keys = state.store.snapshot().keys; + state.apply_committed_editor_keys_without_counters( + outcome.runtime_publication_generation, + &keys, + mode, + ); + } + Some(HistoryAuxChange::Counters(counters)) => { + counters_restored = state.replace_history_counters_locked( + &mut counter_guard, + outcome.runtime_publication_generation, + counters, + ); + } + Some(HistoryAuxChange::PluginElements { .. }) => {} + None => { + if let Some(change) = committed_change.filter(|change| { + change + .result + .changed_fields + .contains(&crate::models::EditorField::Keys) + }) { + state.apply_committed_editor_keys_without_counters( + outcome.runtime_publication_generation, + &change.document.keys, + &change.selected_key_type, + ); + } + } + } + } + let publication_generation = result + .as_ref() + .map(|outcome| outcome.runtime_publication_generation) + .unwrap_or_else(|_| state.store.runtime_publication_generation()); + state.finish_counter_history_barrier( + app, + counter_guard, + counters_restored, + publication_generation, + ); + if let Ok(outcome) = &result { + match outcome.aux_change.as_ref() { + Some(HistoryAuxChange::CustomTabs { .. }) => { + let restored_store = state.store.snapshot(); + let css_guard = state.lock_css_operation(); + state.resync_tab_css_watchers(&restored_store.tab_css_overrides); + drop(css_guard); + } + Some(HistoryAuxChange::PresetFull { .. }) => { + let restored_store = state.store.snapshot(); + let css_guard = state.lock_css_operation(); + state.resync_global_css_watcher(&previous_store, &restored_store); + state.resync_tab_css_watchers(&restored_store.tab_css_overrides); + drop(css_guard); + } + _ => {} + } + } + if let Some(change) = committed_change { + publish_history_editor_change(state, app, change); + } + if let Ok(outcome) = &result { + publish_history_aux_change(state, app, outcome.aux_change.as_ref()); + } + drop(barrier); + + match result { + Ok(_) => { + let status = state.store.history_status(); + emit_best_effort(app, "history:status", &status); + Ok(status) + } + Err(error) => { + let status = state.store.history_status(); + emit_best_effort(app, "history:status", &status); + Err(error) + } + } +} + +fn publish_history_aux_change( + state: &AppState, + app: &AppHandle, + change: Option<&HistoryAuxChange>, +) { + match change { + Some(HistoryAuxChange::CustomTabs { + snapshot, + changed_tab_css_ids, + }) => { + let restored_store = state.store.snapshot(); + emit_best_effort( + app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs: snapshot.custom_tabs.clone(), + selected_key_type: snapshot.selected_key_type.clone(), + }, + ); + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": &snapshot.selected_key_type }), + ); + emit_best_effort( + app, + "tabNote:changed_all", + &restored_store.tab_note_overrides, + ); + for tab_id in changed_tab_css_ids { + emit_best_effort( + app, + "tabCss:changed", + &crate::commands::editor::css::TabCssResponse { + tab_id: tab_id.clone(), + css: restored_store.tab_css_overrides.get(tab_id).cloned(), + }, + ); + } + state.obs_broadcast_counters(); + state.refresh_obs_snapshot(); + } + Some(HistoryAuxChange::PresetFull { + snapshot, + settings_diff, + changed_tab_css_ids, + }) => { + let restored_store = state.store.snapshot(); + let projection = + preset_history_event_projection(snapshot, &restored_store, changed_tab_css_ids); + if let Err(error) = state.emit_settings_changed(settings_diff, app) { + log::error!("failed to publish restored preset settings: {error:#}"); + } + emit_best_effort( + app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs: projection.preset_snapshot.custom_tabs.clone(), + selected_key_type: projection.preset_snapshot.selected_key_type.clone(), + }, + ); + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": &projection.preset_snapshot.selected_key_type }), + ); + emit_best_effort(app, "layerGroups:changed", &snapshot.document.layer_groups); + emit_best_effort(app, "preset:snapshot", &projection.preset_snapshot); + emit_best_effort( + app, + "tabNote:changed_all", + &projection.preset_snapshot.tab_note_overrides, + ); + for payload in &projection.tab_css_changes { + emit_best_effort(app, "tabCss:changed", payload); + } + emit_best_effort( + app, + "css:use", + &serde_json::json!({ "enabled": projection.use_custom_css }), + ); + emit_best_effort(app, "css:content", &projection.custom_css); + emit_best_effort( + app, + "js:use", + &serde_json::json!({ "enabled": projection.use_custom_js }), + ); + emit_best_effort(app, "js:content", &projection.custom_js); + state.obs_broadcast_counters(); + state.refresh_obs_snapshot(); + } + Some(HistoryAuxChange::Mode(mode)) => { + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": mode }), + ); + state.refresh_obs_snapshot(); + } + Some(HistoryAuxChange::Counters(_)) => { + state.obs_broadcast_counters(); + } + Some(HistoryAuxChange::PluginElements { + plugin_id, + revision, + }) => { + publish_plugin_instances_changed( + app, + &PluginInstancesChangedPayload { + plugin_id: plugin_id.clone(), + revision: *revision, + origin_mutation_id: None, + }, + ); + } + None => {} + } +} + +struct PresetHistoryEventProjection { + preset_snapshot: PresetSnapshot, + tab_css_changes: Vec, + use_custom_css: bool, + custom_css: CustomCss, + use_custom_js: bool, + custom_js: CustomJs, +} + +fn preset_history_event_projection( + snapshot: &PresetFullHistorySnapshot, + restored_store: &AppStoreData, + changed_tab_css_ids: &[String], +) -> PresetHistoryEventProjection { + PresetHistoryEventProjection { + preset_snapshot: PresetSnapshot { + keys: snapshot.document.keys.clone(), + positions: snapshot.document.key_positions.clone(), + stat_positions: snapshot.document.stat_positions.clone(), + graph_positions: snapshot.document.graph_positions.clone(), + knob_positions: snapshot.document.knob_positions.clone(), + custom_tabs: snapshot.custom_tabs.clone(), + selected_key_type: snapshot.selected_key_type.clone(), + tab_note_overrides: snapshot.settings.tab_note_overrides.clone(), + }, + tab_css_changes: changed_tab_css_ids + .iter() + .map(|tab_id| crate::commands::editor::css::TabCssResponse { + tab_id: tab_id.clone(), + css: snapshot.tab_css_overrides.get(tab_id).cloned(), + }) + .collect(), + use_custom_css: snapshot.settings.use_custom_css, + custom_css: snapshot.settings.custom_css.clone(), + use_custom_js: snapshot.settings.use_custom_js, + custom_js: restored_store.custom_js.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preset_history_projection_matches_preset_load_payloads() { + let mut restored = AppStoreData { + use_custom_css: true, + custom_css: CustomCss { + path: Some("/tmp/history.css".to_string()), + content: ".history {}".to_string(), + }, + use_custom_js: true, + custom_js: CustomJs { + path: Some("/tmp/history.js".to_string()), + content: "history();".to_string(), + ..CustomJs::default() + }, + ..AppStoreData::default() + }; + restored.tab_css_overrides.insert( + "4key".to_string(), + crate::models::TabCss { + path: Some("/tmp/tab.css".to_string()), + content: ".tab {}".to_string(), + enabled: true, + }, + ); + restored.tab_note_overrides.insert( + "4key".to_string(), + crate::models::TabNoteSettings { + speed: Some(777), + ..crate::models::TabNoteSettings::default() + }, + ); + let snapshot = PresetFullHistorySnapshot::from_store(&restored); + let changed_ids = vec!["4key".to_string()]; + + let projection = preset_history_event_projection(&snapshot, &restored, &changed_ids); + + assert_eq!(projection.preset_snapshot.keys, restored.keys); + assert_eq!(projection.preset_snapshot.positions, restored.key_positions); + assert_eq!( + projection.preset_snapshot.tab_note_overrides, + restored.tab_note_overrides + ); + assert_eq!(projection.use_custom_css, restored.use_custom_css); + assert_eq!(projection.custom_css, restored.custom_css); + assert_eq!(projection.use_custom_js, restored.use_custom_js); + assert_eq!(projection.custom_js, restored.custom_js); + assert_eq!(projection.tab_css_changes.len(), 1); + assert_eq!(projection.tab_css_changes[0].tab_id, "4key"); + assert_eq!( + projection.tab_css_changes[0].css, + restored.tab_css_overrides.get("4key").cloned() + ); + } +} diff --git a/src-tauri/src/commands/editor/js.rs b/src-tauri/src/commands/editor/js.rs index b3f37f43..3bd365d4 100644 --- a/src-tauri/src/commands/editor/js.rs +++ b/src-tauri/src/commands/editor/js.rs @@ -7,9 +7,10 @@ use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; use crate::{ + commands::editor::state::emit_best_effort, errors::CmdResult, models::{CustomJs, JsPlugin}, - state::AppState, + state::{store::AdmittedHistoryOverlapMutation, AppState}, }; #[derive(Serialize)] @@ -81,19 +82,14 @@ fn emit_js_state(app: &AppHandle, script: &CustomJs) -> CmdResult<()> { fn get_normalized_script(state: &State) -> CmdResult { let mut script = state.store.snapshot().custom_js; - if script.normalize() { - state.store.update(|store| { - store.custom_js = script.clone(); - })?; - } + let _ = script.normalize(); Ok(script) } -fn persist_script(state: &State, script: &CustomJs) -> CmdResult { - let data = state.store.update(|store| { - store.custom_js = script.clone(); - })?; - Ok(data.custom_js.clone()) +fn emit_history_status(app: &AppHandle, transaction: &AdmittedHistoryOverlapMutation) { + if let Some(status) = transaction.history_status.as_ref() { + emit_best_effort(app, "history:status", status); + } } #[tauri::command] @@ -112,15 +108,19 @@ pub fn js_toggle( app: AppHandle, enabled: bool, ) -> CmdResult { - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { store.use_custom_js = enabled; + if enabled { + let _ = store.custom_js.normalize(); + } + Ok(store.custom_js.clone()) })?; + emit_history_status(&app, &transaction); app.emit("js:use", &JsToggleResponse { enabled })?; if enabled { - let script = get_normalized_script(&state)?; - emit_js_state(&app, &script)?; + emit_js_state(&app, &transaction.value)?; } Ok(JsToggleResponse { enabled }) @@ -130,11 +130,13 @@ pub fn js_toggle( pub fn js_reset(state: State<'_, AppState>, app: AppHandle) -> CmdResult<()> { let default = CustomJs::default(); - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { store.use_custom_js = false; store.custom_js = default.clone(); + Ok(store.custom_js.clone()) })?; + emit_history_status(&app, &transaction); app.emit("js:use", &JsToggleResponse { enabled: false })?; emit_js_state(&app, &default)?; Ok(()) @@ -146,18 +148,21 @@ pub fn js_set_content( app: AppHandle, content: String, ) -> CmdResult { - let mut script = get_normalized_script(&state)?; - if script.plugins.is_empty() { - script.content = content.clone(); - } else if let Some(plugin) = script.plugins.iter_mut().find(|plugin| plugin.enabled) { - plugin.content = content.clone(); - } else if let Some(plugin) = script.plugins.first_mut() { - plugin.content = content.clone(); - } - - let _ = script.normalize(); - let updated = persist_script(&state, &script)?; - emit_js_state(&app, &updated)?; + let transaction = state.store.commit_history_overlap_mutation(|store| { + let script = &mut store.custom_js; + let _ = script.normalize(); + if script.plugins.is_empty() { + script.content = content.clone(); + } else if let Some(plugin) = script.plugins.iter_mut().find(|plugin| plugin.enabled) { + plugin.content = content.clone(); + } else if let Some(plugin) = script.plugins.first_mut() { + plugin.content = content.clone(); + } + let _ = script.normalize(); + Ok(script.clone()) + })?; + emit_history_status(&app, &transaction); + emit_js_state(&app, &transaction.value)?; Ok(JsSetContentResponse { success: true, @@ -194,7 +199,6 @@ pub fn js_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult { - let mut script = get_normalized_script(&state)?; - let mut updated_plugins = Vec::new(); + let script = get_normalized_script(&state)?; + let mut loaded_plugins = Vec::new(); let mut errors = Vec::new(); - for plugin in script.plugins.iter_mut() { + for plugin in &script.plugins { let Some(ref path) = plugin.path else { continue; }; match fs::read_to_string(path) { - Ok(content) => { - plugin.content = content.clone(); - updated_plugins.push(plugin.clone()); - } + Ok(content) => loaded_plugins.push((plugin.id.clone(), path.clone(), content)), Err(err) => errors.push(JsPluginError::new(path.clone(), err.to_string())), } } - let _ = script.normalize(); - let updated = persist_script(&state, &script)?; - emit_js_state(&app, &updated)?; + let transaction = state.store.commit_history_overlap_mutation(|store| { + let script = &mut store.custom_js; + let _ = script.normalize(); + let mut updated_plugins = Vec::new(); + for (id, expected_path, content) in &loaded_plugins { + if let Some(plugin) = script + .plugins + .iter_mut() + .find(|plugin| plugin.id == *id && plugin.path.as_ref() == Some(expected_path)) + { + plugin.content.clone_from(content); + updated_plugins.push(plugin.clone()); + } + } + let _ = script.normalize(); + Ok((script.clone(), updated_plugins)) + })?; + emit_history_status(&app, &transaction); + emit_js_state(&app, &transaction.value.0)?; Ok(JsReloadResponse { - updated: updated_plugins, + updated: transaction.value.1.clone(), errors, }) } @@ -270,20 +291,28 @@ pub fn js_remove_plugin( app: AppHandle, id: String, ) -> CmdResult { - let mut script = get_normalized_script(&state)?; info!("js_remove_plugin: requested id={}", id); + let current = get_normalized_script(&state)?; info!( "js_remove_plugin: existing ids={}", - script + current .plugins .iter() .map(|p| p.id.as_str()) .collect::>() .join(",") ); - let initial_len = script.plugins.len(); - script.plugins.retain(|plugin| plugin.id != id); - if script.plugins.len() == initial_len { + let transaction = state.store.commit_history_overlap_mutation(|store| { + let script = &mut store.custom_js; + let _ = script.normalize(); + let initial_len = script.plugins.len(); + script.plugins.retain(|plugin| plugin.id != id); + let removed = script.plugins.len() != initial_len; + let _ = script.normalize(); + Ok((script.clone(), removed)) + })?; + emit_history_status(&app, &transaction); + if !transaction.value.1 { info!("js_remove_plugin: id not found"); return Ok(JsRemoveResponse { success: false, @@ -292,9 +321,7 @@ pub fn js_remove_plugin( }); } - let _ = script.normalize(); - let updated = persist_script(&state, &script)?; - emit_js_state(&app, &updated)?; + emit_js_state(&app, &transaction.value.0)?; Ok(JsRemoveResponse { success: true, @@ -310,35 +337,39 @@ pub fn js_set_plugin_enabled( id: String, enabled: bool, ) -> CmdResult { - let mut script = get_normalized_script(&state)?; + let current = get_normalized_script(&state)?; info!( "js_set_plugin_enabled: id={} enabled={} (existing ids={})", id, enabled, - script + current .plugins .iter() .map(|p| p.id.as_str()) .collect::>() .join(",") ); - let mut updated_plugin = None; - - for plugin in script.plugins.iter_mut() { - if plugin.id == id { - plugin.enabled = enabled; - updated_plugin = Some(plugin.clone()); - break; - } - } + let transaction = state.store.commit_history_overlap_mutation(|store| { + let script = &mut store.custom_js; + let _ = script.normalize(); + let updated_plugin = script.plugins.iter_mut().find_map(|plugin| { + (plugin.id == id).then(|| { + plugin.enabled = enabled; + plugin.clone() + }) + }); + let _ = script.normalize(); + Ok((script.clone(), updated_plugin)) + })?; - if updated_plugin.is_none() { + emit_history_status(&app, &transaction); + if transaction.value.1.is_none() { // 요청 id와 각 플러그인 path/name 로깅 info!( "js_set_plugin_enabled: failed to match id={} among {} plugins (names={})", id, - script.plugins.len(), - script + current.plugins.len(), + current .plugins .iter() .map(|p| format!("{}:{}", p.id, p.name)) @@ -347,7 +378,7 @@ pub fn js_set_plugin_enabled( ); } - let Some(plugin) = updated_plugin else { + let Some(plugin) = transaction.value.1.clone() else { return Ok(JsPluginUpdateResponse { success: false, plugin: None, @@ -355,9 +386,7 @@ pub fn js_set_plugin_enabled( }); }; - let _ = script.normalize(); - let updated = persist_script(&state, &script)?; - emit_js_state(&app, &updated)?; + emit_js_state(&app, &transaction.value.0)?; Ok(JsPluginUpdateResponse { success: true, diff --git a/src-tauri/src/commands/editor/mod.rs b/src-tauri/src/commands/editor/mod.rs index b847a3e0..b4d43bd5 100644 --- a/src-tauri/src/commands/editor/mod.rs +++ b/src-tauri/src/commands/editor/mod.rs @@ -1,4 +1,7 @@ pub mod css; +pub mod history; pub mod js; pub mod note_tab; +pub mod preview; +pub mod selection; pub mod state; diff --git a/src-tauri/src/commands/editor/note_tab.rs b/src-tauri/src/commands/editor/note_tab.rs index 1a9e3b5a..cfed7158 100644 --- a/src-tauri/src/commands/editor/note_tab.rs +++ b/src-tauri/src/commands/editor/note_tab.rs @@ -2,6 +2,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use crate::{ + commands::editor::state::emit_best_effort, errors::CmdResult, models::{TabNoteOverrides, TabNoteSettings}, state::AppState, @@ -52,22 +53,24 @@ pub fn note_tab_set( tab_id: String, settings: Option, ) -> CmdResult { - if let Some(ref note_settings) = settings { - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { + if let Some(ref note_settings) = settings { store .tab_note_overrides .insert(tab_id.clone(), note_settings.clone()); - })?; - } else { - state.store.update(|store| { + } else { store.tab_note_overrides.remove(&tab_id); - })?; - } + } + Ok(()) + })?; let response = TabNoteResponse { tab_id: tab_id.clone(), settings: settings.clone(), }; + if let Some(status) = transaction.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } app.emit("tabNote:changed", &response)?; state.refresh_obs_snapshot(); @@ -85,14 +88,18 @@ pub fn note_tab_clear( app: AppHandle, tab_id: String, ) -> CmdResult { - state.store.update(|store| { + let transaction = state.store.commit_history_overlap_mutation(|store| { store.tab_note_overrides.remove(&tab_id); + Ok(()) })?; let response = TabNoteResponse { tab_id: tab_id.clone(), settings: None, }; + if let Some(status) = transaction.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } app.emit("tabNote:changed", &response)?; state.refresh_obs_snapshot(); diff --git a/src-tauri/src/commands/editor/preview.rs b/src-tauri/src/commands/editor/preview.rs new file mode 100644 index 00000000..9298703a --- /dev/null +++ b/src-tauri/src/commands/editor/preview.rs @@ -0,0 +1,31 @@ +use tauri::{ipc::Channel, State, WebviewWindow}; + +use crate::services::preview_broker::{PreviewBroker, PreviewEnvelope, PreviewPublishRequest}; + +#[tauri::command] +pub fn editor_preview_subscribe( + broker: State<'_, PreviewBroker>, + window: WebviewWindow, + channel: Channel, +) -> Result { + broker.subscribe(window.label(), channel) +} + +#[tauri::command] +pub fn editor_preview_publish( + broker: State<'_, PreviewBroker>, + window: WebviewWindow, + request: PreviewPublishRequest, +) -> Result<(), String> { + broker.publish(window.label(), request) +} + +#[tauri::command] +pub fn editor_preview_cancel( + broker: State<'_, PreviewBroker>, + window: WebviewWindow, + session_id: String, + min_revision: Option, +) -> Result<(), String> { + broker.cancel(window.label(), &session_id, min_revision) +} diff --git a/src-tauri/src/commands/editor/selection.rs b/src-tauri/src/commands/editor/selection.rs new file mode 100644 index 00000000..dbf6ebd6 --- /dev/null +++ b/src-tauri/src/commands/editor/selection.rs @@ -0,0 +1,19 @@ +use tauri::{AppHandle, State}; + +use crate::state::{AppState, SelectionSessionSnapshot}; + +#[tauri::command] +pub fn selection_session_get( + state: State<'_, AppState>, +) -> Result { + Ok(state.selection_session()) +} + +#[tauri::command] +pub fn selection_session_publish( + state: State<'_, AppState>, + app: AppHandle, + snapshot: SelectionSessionSnapshot, +) -> Result { + state.publish_selection_session(&app, snapshot) +} diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs index 00eeda68..41f81f38 100644 --- a/src-tauri/src/commands/editor/state.rs +++ b/src-tauri/src/commands/editor/state.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, State, WebviewWindow}; use crate::{ errors::CmdResult, @@ -6,6 +6,7 @@ use crate::{ CommittedEditorChange, EditorCommitRequest, EditorCommitResult, EditorField, EditorGetResult, }, + services::preview_broker::PreviewBroker, state::AppState, }; @@ -15,6 +16,34 @@ pub(crate) fn emit_best_effort(app: &AppHandle, event: &str } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum KeyRuntimePublishPolicy { + ApplyWithCounters, + AlreadyAppliedWithoutCounters, +} + +fn legacy_event_name(field: EditorField) -> &'static str { + match field { + EditorField::Keys => "keys:changed", + EditorField::KeyPositions => "positions:changed", + EditorField::StatPositions => "statPositions:changed", + EditorField::GraphPositions => "graphPositions:changed", + EditorField::KnobPositions => "knobPositions:changed", + EditorField::LayerGroups => "layerGroups:changed", + } +} + +fn projected_legacy_fields( + project_legacy_events: bool, + changed_fields: &[EditorField], +) -> &[EditorField] { + if project_legacy_events { + changed_fields + } else { + &[] + } +} + pub(crate) fn publish_legacy_editor_fields( state: &AppState, app: &AppHandle, @@ -22,36 +51,25 @@ pub(crate) fn publish_legacy_editor_fields( fields: &[EditorField], ) { for field in fields { + let event = legacy_event_name(*field); match field { EditorField::Keys => { - emit_best_effort(app, "keys:changed", &change.document.keys); + emit_best_effort(app, event, &change.document.keys); } EditorField::KeyPositions => { - emit_best_effort(app, "positions:changed", &change.document.key_positions); + emit_best_effort(app, event, &change.document.key_positions); } EditorField::StatPositions => { - emit_best_effort( - app, - "statPositions:changed", - &change.document.stat_positions, - ); + emit_best_effort(app, event, &change.document.stat_positions); } EditorField::GraphPositions => { - emit_best_effort( - app, - "graphPositions:changed", - &change.document.graph_positions, - ); + emit_best_effort(app, event, &change.document.graph_positions); } EditorField::KnobPositions => { - emit_best_effort( - app, - "knobPositions:changed", - &change.document.knob_positions, - ); + emit_best_effort(app, event, &change.document.knob_positions); } EditorField::LayerGroups => { - emit_best_effort(app, "layerGroups:changed", &change.document.layer_groups); + emit_best_effort(app, event, &change.document.layer_groups); } } } @@ -59,14 +77,11 @@ pub(crate) fn publish_legacy_editor_fields( if fields.contains(&EditorField::Keys) && !change.result.changed_fields.contains(&EditorField::Keys) { - if let Err(error) = state.apply_committed_editor_key_runtime( - app, + state.apply_committed_editor_keys_without_counters( + change.runtime_publication_generation, &change.document.keys, &change.selected_key_type, - &change.key_counters, - ) { - log::error!("[Editor] failed to publish legacy key refresh: {error:#}"); - } + ); state.obs_broadcast_counters(); } @@ -80,6 +95,24 @@ pub(crate) fn publish_editor_change( app: &AppHandle, change: &CommittedEditorChange, project_legacy_events: bool, +) { + let legacy_fields = + projected_legacy_fields(project_legacy_events, &change.result.changed_fields); + publish_editor_change_with_options( + state, + app, + change, + legacy_fields, + KeyRuntimePublishPolicy::ApplyWithCounters, + ); +} + +fn publish_editor_change_with_options( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, + legacy_fields: &[EditorField], + key_runtime_policy: KeyRuntimePublishPolicy, ) { let Some(event) = change.event.as_ref() else { return; @@ -87,10 +120,13 @@ pub(crate) fn publish_editor_change( emit_best_effort(app, "editor:committed", event); let keys_changed = change.result.changed_fields.contains(&EditorField::Keys); - let previous_mode = keys_changed.then(|| state.keyboard.current_mode()); - if keys_changed { + let previous_mode = (keys_changed + && key_runtime_policy == KeyRuntimePublishPolicy::ApplyWithCounters) + .then(|| state.keyboard.current_mode()); + if keys_changed && key_runtime_policy == KeyRuntimePublishPolicy::ApplyWithCounters { if let Err(error) = state.apply_committed_editor_key_runtime( app, + change.runtime_publication_generation, &change.document.keys, &change.selected_key_type, &change.key_counters, @@ -99,11 +135,11 @@ pub(crate) fn publish_editor_change( } } - if project_legacy_events && !change.result.changed_fields.is_empty() { - publish_legacy_editor_fields(state, app, change, &change.result.changed_fields); + if !legacy_fields.is_empty() { + publish_legacy_editor_fields(state, app, change, legacy_fields); } - if project_legacy_events && previous_mode.is_some_and(|mode| mode != change.selected_key_type) { + if previous_mode.is_some_and(|mode| mode != change.selected_key_type) { emit_best_effort( app, "keys:mode-changed", @@ -111,7 +147,7 @@ pub(crate) fn publish_editor_change( ); } - if keys_changed { + if keys_changed && key_runtime_policy == KeyRuntimePublishPolicy::ApplyWithCounters { state.obs_broadcast_counters(); } @@ -120,6 +156,34 @@ pub(crate) fn publish_editor_change( } } +pub(crate) fn publish_history_editor_change( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, +) { + publish_editor_change_with_options( + state, + app, + change, + &change.result.changed_fields, + KeyRuntimePublishPolicy::AlreadyAppliedWithoutCounters, + ); +} + +pub(crate) fn publish_editor_change_after_key_runtime( + state: &AppState, + app: &AppHandle, + change: &CommittedEditorChange, +) { + publish_editor_change_with_options( + state, + app, + change, + &[], + KeyRuntimePublishPolicy::AlreadyAppliedWithoutCounters, + ); +} + #[tauri::command] pub fn editor_get(state: State<'_, AppState>) -> CmdResult { Ok(state.store.editor_get()) @@ -128,14 +192,22 @@ pub fn editor_get(state: State<'_, AppState>) -> CmdResult { #[tauri::command] pub fn editor_commit( state: State<'_, AppState>, + broker: State<'_, PreviewBroker>, app: AppHandle, + window: WebviewWindow, request: EditorCommitRequest, ) -> CmdResult { + let admission = state + .admit_frontend_history_mutation(window.label()) + .map_err(|_| crate::errors::EditorCommitError::history_in_progress())?; + let gesture_id = request.gesture_id.clone(); let requested_fields = request.changes.included_fields(); let previous_mode = requested_fields .contains(&EditorField::Keys) .then(|| state.keyboard.current_mode()); - let change = state.store.commit_editor_document(request)?; + let change = state + .store + .commit_editor_document_admitted(request, &admission)?; if change.event.is_some() { publish_editor_change(state.inner(), &app, &change, false); } @@ -149,5 +221,47 @@ pub fn editor_commit( ); } } + if let Some(gesture_id) = gesture_id { + if let Err(error) = broker.finish_committed_session( + window.label(), + &gesture_id, + Some(change.result.revision), + ) { + log::warn!("failed to finish committed preview session: {error}"); + } + } + if let Some(history_status) = change.history_status.as_ref() { + emit_best_effort(&app, "history:status", history_status); + } + drop(admission); Ok(change.result) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_restore_uses_same_legacy_projection_for_every_editor_field() { + let fields = [ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ]; + assert_eq!(&fields, projected_legacy_fields(true, &fields)); + assert_eq!( + fields.map(legacy_event_name), + [ + "keys:changed", + "positions:changed", + "statPositions:changed", + "graphPositions:changed", + "knobPositions:changed", + "layerGroups:changed", + ] + ); + } +} diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index ad628ab1..9addb20d 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -4,40 +4,40 @@ use serde::Serialize; use tauri::{AppHandle, State}; use crate::{ - commands::editor::state::{ - emit_best_effort, publish_editor_change, publish_legacy_editor_fields, - }, + commands::editor::state::{emit_best_effort, publish_editor_change}, defaults::{default_keys, default_positions}, errors::CmdResult, models::{ AppStoreData, CommittedEditorChange, CustomCssPatch, CustomTab, EditorCommitOrigin, - EditorCommitResult, EditorDocumentV1, EditorField, EditorHistoryRestoreRequest, - KeyCounters, KeyMappings, KeyPositions, LayerGroups, NoteSettings, NoteSettingsPatch, - SettingsPatchInput, + EditorDocumentV1, EditorField, KeyCounters, KeyMappings, KeyPositions, LayerGroups, + NoteSettings, NoteSettingsPatch, SettingsPatchInput, }, services::settings::apply_patch_to_store, - state::{editor::validate_history_restore_metadata, AppState}, + state::{editor::validate_history_restore_metadata, history::HistoryScope, AppState}, }; const MAX_CUSTOM_TABS: usize = 30; fn publish_legacy_key_noop_runtime( state: &AppState, - app: &AppHandle, + _app: &AppHandle, change: &CommittedEditorChange, ) { - if let Err(error) = state.apply_committed_editor_key_runtime( - app, + state.apply_committed_editor_keys_without_counters( + change.runtime_publication_generation, &change.document.keys, &change.selected_key_type, - &change.key_counters, - ) { - log::error!("[Keys] failed to publish committed key counters: {error:#}"); - } + ); state.obs_broadcast_counters(); state.refresh_obs_snapshot(); } +fn emit_aux_history_status(app: &AppHandle, change: &CommittedEditorChange) { + if let Some(status) = change.history_status.as_ref() { + emit_best_effort(app, "history:status", status); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ModeResetKind { Default, @@ -89,6 +89,15 @@ fn is_selectable_mode(store: &AppStoreData, mode: &str) -> bool { || (store.keys.contains_key(mode) && store.custom_tabs.iter().any(|tab| tab.id == mode)) } +fn select_mode_if_available(store: &mut AppStoreData, requested: &str) -> (bool, String) { + if !is_selectable_mode(store, requested) { + return (false, store.selected_key_type.clone()); + } + store.selected_key_type = requested.to_string(); + (true, store.selected_key_type.clone()) +} + +#[cfg(test)] fn set_mode_with( store: &AppStoreData, requested: String, @@ -208,13 +217,6 @@ pub struct ResetModeResponse { pub mode: String, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct KeysWithPositionsResponse { - pub keys: KeyMappings, - pub positions: KeyPositions, -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CustomTabChangePayload { @@ -254,131 +256,40 @@ pub fn keys_get_counters(state: State<'_, AppState>) -> CmdResult { } #[tauri::command] -pub fn keys_update( - state: State<'_, AppState>, - app: AppHandle, - mappings: KeyMappings, -) -> CmdResult { - let previous_mode = state.keyboard.current_mode(); - let transaction = state.store.commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("keys_update".to_string()), - &[EditorField::Keys], - move |store| { - store.keys = mappings; - Ok(()) - }, - )?; - publish_editor_change(state.inner(), &app, &transaction.change, false); - if !transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys) - { - publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); - } - let updated = transaction.change.document.keys; - emit_best_effort(&app, "keys:changed", &updated); - if previous_mode != transaction.change.selected_key_type { - emit_best_effort( - &app, - "keys:mode-changed", - &serde_json::json!({ "mode": &transaction.change.selected_key_type }), - ); - } - Ok(updated) -} - -#[tauri::command] -pub fn keys_update_with_positions( +pub fn keys_set_mode( state: State<'_, AppState>, app: AppHandle, - mappings: KeyMappings, - positions: KeyPositions, -) -> CmdResult { - let previous_mode = state.keyboard.current_mode(); - let transaction = state.store.commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("keys_update_with_positions".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - move |store| { - store.keys = mappings; - store.key_positions = positions; - Ok(()) - }, + mode: String, + observed_history_epoch: Option, +) -> CmdResult { + let requested = mode.clone(); + let transaction = state.store.commit_aux_editor_transaction( + HistoryScope::Mode, + observed_history_epoch, + EditorCommitOrigin::LegacyAdapter("keys_set_mode".to_string()), + &[], + move |store| Ok(select_mode_if_available(store, &requested)), )?; - publish_editor_change(state.inner(), &app, &transaction.change, false); - if !transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys) + let (success, effective) = transaction.value.clone(); + if success + && state.apply_committed_editor_keys_without_counters( + transaction.change.runtime_publication_generation, + &transaction.change.document.keys, + &effective, + ) { - publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); - } - let keys = transaction.change.document.keys; - let positions = transaction.change.document.key_positions; - emit_best_effort(&app, "keys:changed", &keys); - emit_best_effort(&app, "positions:changed", &positions); - if previous_mode != transaction.change.selected_key_type { emit_best_effort( &app, "keys:mode-changed", - &serde_json::json!({ "mode": &transaction.change.selected_key_type }), + &serde_json::json!({ "mode": &effective }), ); - } - Ok(KeysWithPositionsResponse { keys, positions }) -} - -#[tauri::command] -pub fn positions_update( - state: State<'_, AppState>, - app: AppHandle, - positions: KeyPositions, -) -> CmdResult { - let transaction = state.store.commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("positions_update".to_string()), - &[EditorField::KeyPositions], - move |store| { - store.key_positions = positions; - Ok(()) - }, - )?; - publish_editor_change(state.inner(), &app, &transaction.change, false); - let updated = transaction.change.document.key_positions; - emit_best_effort(&app, "positions:changed", &updated); - if transaction.change.event.is_none() { state.refresh_obs_snapshot(); } - Ok(updated) -} - -#[tauri::command] -pub fn keys_set_mode( - state: State<'_, AppState>, - app: AppHandle, - mode: String, -) -> CmdResult { - let snapshot = state.store.snapshot(); - set_mode_with( - &snapshot, - mode, - |candidate| { - state - .store - .set_selected_key_type(candidate) - .map_err(Into::into) - }, - |effective| { - state.keyboard.set_mode(effective.to_string()); - emit_best_effort( - &app, - "keys:mode-changed", - &serde_json::json!({ "mode": effective }), - ); - state.refresh_obs_snapshot(); - Ok(()) - }, - ) + emit_aux_history_status(&app, &transaction.change); + Ok(ModeResponse { + success, + mode: effective, + }) } #[tauri::command] @@ -604,6 +515,7 @@ pub fn custom_tabs_create( state: State<'_, AppState>, app: AppHandle, name: String, + observed_history_epoch: Option, ) -> CmdResult { if name.trim().is_empty() { return Ok(CustomTabCreateResult { @@ -618,7 +530,9 @@ pub fn custom_tabs_create( id: id.clone(), name: trimmed, }; - let transaction = state.store.commit_legacy_editor_transaction( + let transaction = state.store.commit_aux_editor_transaction( + HistoryScope::CustomTabs, + observed_history_epoch, EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), &[EditorField::Keys, EditorField::KeyPositions], |store| { @@ -669,6 +583,7 @@ pub fn custom_tabs_create( "keys:mode-changed", &serde_json::json!({ "mode": &id }), ); + emit_aux_history_status(&app, &transaction.change); Ok(CustomTabCreateResult { result: Some(tab), @@ -681,8 +596,11 @@ pub fn custom_tabs_delete( state: State<'_, AppState>, app: AppHandle, id: String, + observed_history_epoch: Option, ) -> CmdResult { - let transaction = state.store.commit_legacy_editor_transaction( + let transaction = state.store.commit_aux_editor_transaction( + HistoryScope::CustomTabs, + observed_history_epoch, EditorCommitOrigin::LegacyAdapter("custom_tabs_delete".to_string()), &[ EditorField::Keys, @@ -765,6 +683,7 @@ pub fn custom_tabs_delete( "keys:mode-changed", &serde_json::json!({ "mode": &selected_key_type }), ); + emit_aux_history_status(&app, &transaction.change); Ok(CustomTabDeleteResult { success: true, @@ -786,42 +705,52 @@ pub fn custom_tabs_select( state: State<'_, AppState>, app: AppHandle, id: String, + observed_history_epoch: Option, ) -> CmdResult { - let snapshot = state.store.snapshot(); - if !is_selectable_mode(&snapshot, &id) { - return Ok(CustomTabSelectResult { - success: false, - selected: snapshot.selected_key_type, - error: Some("not-found".to_string()), - }); + let requested = id; + let transaction = state.store.commit_aux_editor_transaction( + HistoryScope::Mode, + observed_history_epoch, + EditorCommitOrigin::LegacyAdapter("custom_tabs_select".to_string()), + &[], + move |store| Ok(select_mode_if_available(store, &requested)), + )?; + let (success, selected) = transaction.value.clone(); + if success + && state.apply_committed_editor_keys_without_counters( + transaction.change.runtime_publication_generation, + &transaction.change.document.keys, + &selected, + ) + { + emit_best_effort( + &app, + "keys:mode-changed", + &serde_json::json!({ "mode": &selected }), + ); + state.refresh_obs_snapshot(); } - - let selected = state.store.set_selected_key_type(id)?; - state.keyboard.set_mode(selected.clone()); - - emit_best_effort( - &app, - "keys:mode-changed", - &serde_json::json!({ "mode": &selected }), - ); - state.refresh_obs_snapshot(); + emit_aux_history_status(&app, &transaction.change); Ok(CustomTabSelectResult { - success: true, + success, selected, - error: None, + error: (!success).then(|| "not-found".to_string()), }) } -/// undo/redo 시 커스텀 탭 목록 + 모드를 원자적으로 복원 +/// 커스텀 탭 목록과 선택 모드를 원자적으로 복원 #[tauri::command] pub fn custom_tabs_restore( state: State<'_, AppState>, app: AppHandle, custom_tabs: Vec, selected_key_type: String, + observed_history_epoch: Option, ) -> CmdResult<()> { - let transaction = state.store.commit_legacy_editor_transaction( + let transaction = state.store.commit_aux_editor_transaction( + HistoryScope::CustomTabs, + observed_history_epoch, EditorCommitOrigin::LegacyAdapter("custom_tabs_restore".to_string()), &[], move |store| { @@ -837,8 +766,11 @@ pub fn custom_tabs_restore( )?; let (custom_tabs, selected_key_type) = transaction.value; - state.keyboard.set_mode(selected_key_type.clone()); - + state.apply_committed_editor_keys_without_counters( + transaction.change.runtime_publication_generation, + &transaction.change.document.keys, + &selected_key_type, + ); emit_best_effort( &app, "customTabs:changed", @@ -853,66 +785,22 @@ pub fn custom_tabs_restore( &serde_json::json!({ "mode": &selected_key_type }), ); state.refresh_obs_snapshot(); + emit_aux_history_status(&app, &transaction.change); Ok(()) } -/// Undo/Redo의 결합 상태를 revision 선조건 아래 한 번에 복원 #[tauri::command] -pub fn editor_history_restore( +pub fn keys_reset_counters( state: State<'_, AppState>, app: AppHandle, - request: EditorHistoryRestoreRequest, -) -> CmdResult { - let transaction = state.store.restore_editor_history(request)?; - let changed_fields = transaction.change.result.changed_fields.clone(); - - publish_editor_change(state.inner(), &app, &transaction.change, false); - publish_legacy_editor_fields(state.inner(), &app, &transaction.change, &changed_fields); - if !changed_fields.contains(&EditorField::Keys) { - publish_legacy_key_noop_runtime(state.inner(), &app, &transaction.change); - } - - let snapshot = state.store.snapshot(); - emit_best_effort( - &app, - "customTabs:changed", - &CustomTabChangePayload { - custom_tabs: snapshot.custom_tabs.clone(), - selected_key_type: snapshot.selected_key_type.clone(), - }, - ); - emit_best_effort( - &app, - "keys:mode-changed", - &serde_json::json!({ "mode": &snapshot.selected_key_type }), - ); - emit_best_effort(&app, "tabNote:changed_all", &snapshot.tab_note_overrides); - emit_best_effort( - &app, - "css:use", - &serde_json::json!({ "enabled": snapshot.use_custom_css }), - ); - emit_best_effort(&app, "css:content", &snapshot.custom_css); - emit_best_effort( - &app, - "js:use", - &serde_json::json!({ "enabled": snapshot.use_custom_js }), - ); - emit_best_effort(&app, "js:content", &snapshot.custom_js); - - if let Some(diff) = transaction.value.as_ref() { - if let Err(error) = state.emit_settings_changed(diff, &app) { - log::error!("[History] failed to publish restored settings: {error:#}"); - } - } - Ok(transaction.change.result) -} - -#[tauri::command] -pub fn keys_reset_counters(state: State<'_, AppState>, app: AppHandle) -> CmdResult { - let snapshot = state.reset_key_counters(&app)?; + observed_history_epoch: Option, +) -> CmdResult { + let mutation = state.reset_key_counters(&app, observed_history_epoch)?; state.obs_broadcast_counters(); - Ok(snapshot) + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(mutation.counters) } #[tauri::command] @@ -920,10 +808,14 @@ pub fn keys_reset_counters_mode( state: State<'_, AppState>, app: AppHandle, mode: String, + observed_history_epoch: Option, ) -> CmdResult { - let snapshot = state.reset_mode_counters(&app, &mode)?; + let mutation = state.reset_mode_counters(&app, &mode, observed_history_epoch)?; state.obs_broadcast_counters(); - Ok(snapshot) + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(mutation.counters) } #[tauri::command] @@ -932,10 +824,14 @@ pub fn keys_reset_single_counter( app: AppHandle, mode: String, key: String, + observed_history_epoch: Option, ) -> CmdResult { - let snapshot = state.reset_single_key_counter(&app, &mode, &key)?; + let mutation = state.reset_single_key_counter(&app, &mode, &key, observed_history_epoch)?; state.obs_broadcast_counters(); - Ok(snapshot) + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(mutation.counters) } #[tauri::command] @@ -943,11 +839,14 @@ pub fn keys_set_counters( state: State<'_, AppState>, app: AppHandle, counters: KeyCounters, + observed_history_epoch: Option, ) -> CmdResult { - let keys_snapshot = state.store.snapshot().keys; - let updated = state.replace_key_counters(&app, counters, &keys_snapshot)?; + let mutation = state.replace_key_counters(&app, counters, observed_history_epoch)?; state.obs_broadcast_counters(); - Ok(updated) + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(mutation.counters) } #[tauri::command] @@ -955,29 +854,6 @@ pub fn layer_groups_get(state: State<'_, AppState>) -> CmdResult { Ok(state.store.snapshot().layer_groups) } -#[tauri::command] -pub fn layer_groups_update( - state: State<'_, AppState>, - app: AppHandle, - groups: LayerGroups, -) -> CmdResult { - let transaction = state.store.commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("layer_groups_update".to_string()), - &[EditorField::LayerGroups], - move |store| { - store.layer_groups = groups; - Ok(()) - }, - )?; - publish_editor_change(state.inner(), &app, &transaction.change, false); - let updated = transaction.change.document.layer_groups; - emit_best_effort(&app, "layerGroups:changed", &updated); - if transaction.change.event.is_none() { - state.refresh_obs_snapshot(); - } - Ok(updated) -} - fn generate_custom_tab_id() -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1011,7 +887,7 @@ pub fn raw_input_unsubscribe(state: State<'_, AppState>) -> CmdResult, + app: AppHandle, + window: WebviewWindow, + view_state: PanelViewState, +) -> CmdResult<()> { + if window.label() != "main" { + return Err(anyhow!("panel window can only be opened from main").into()); + } + Ok(state.show_panel_window(&app, view_state)?) +} + +#[tauri::command] +pub fn panel_window_close( + state: State<'_, AppState>, + app: AppHandle, + window: WebviewWindow, + view_state: PanelViewState, +) -> CmdResult<()> { + if window.label() != crate::state::PANEL_LABEL { + return Err(anyhow!("panel window can only be closed from panel").into()); + } + Ok(state.close_panel_window(&app, view_state)?) +} + +#[tauri::command] +pub fn panel_window_take_view_state( + state: State<'_, AppState>, + window: WebviewWindow, +) -> Option { + state.take_panel_view_state(window.label()) +} + +#[tauri::command] +pub fn panel_window_close_ack(state: State<'_, AppState>, request_id: String) -> CmdResult { + Ok(state.acknowledge_panel_window_close(&request_id)) +} + +#[tauri::command] +pub fn panel_window_is_open(state: State<'_, AppState>, app: AppHandle) -> CmdResult { + Ok(state.is_panel_window_open(&app)) +} + +#[tauri::command] +pub fn panel_window_start_dragging(app: AppHandle, client_x: f64, client_y: f64) -> CmdResult<()> { + let window = app + .get_webview_window(crate::state::PANEL_LABEL) + .ok_or_else(|| anyhow!("panel window is not open"))?; + start_panel_window_dragging(&window, client_x, client_y)?; + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn start_panel_window_dragging( + window: &WebviewWindow, + _client_x: f64, + _client_y: f64, +) -> anyhow::Result<()> { + window + .start_dragging() + .context("failed to start panel window dragging") +} + +#[cfg(target_os = "macos")] +fn start_panel_window_dragging( + window: &WebviewWindow, + client_x: f64, + client_y: f64, +) -> anyhow::Result<()> { + use cocoa::{ + appkit::{NSEvent, NSEventModifierFlags, NSEventType}, + base::{id, nil}, + foundation::{NSInteger, NSPoint, NSRect, NSUInteger}, + }; + use objc::{class, msg_send, sel, sel_impl}; + + let ns_window = + window.ns_window().context("failed to get panel NSWindow")? as *mut objc::runtime::Object; + + unsafe { + let application: id = msg_send![class!(NSApplication), sharedApplication]; + let current_event: id = msg_send![application, currentEvent]; + let target_window_number: NSInteger = msg_send![ns_window, windowNumber]; + + let use_current_event = if current_event.is_null() { + false + } else { + let event_type: NSUInteger = msg_send![current_event, type]; + let event_window_number: NSInteger = msg_send![current_event, windowNumber]; + is_panel_mouse_down_event(event_type, event_window_number, target_window_number) + }; + + let drag_event = if use_current_event { + current_event + } else { + let content_view: id = msg_send![ns_window, contentView]; + if content_view.is_null() { + return Err(anyhow!("failed to get panel content view")); + } + let content_bounds: NSRect = msg_send![content_view, bounds]; + let (local_x, local_y) = panel_drag_local_coordinates( + client_x, + client_y, + content_bounds.size.width, + content_bounds.size.height, + ) + .ok_or_else(|| anyhow!("invalid panel drag coordinates"))?; + let window_location = NSPoint::new(local_x, local_y); + let (modifier_flags, timestamp, event_number) = if current_event.is_null() { + (NSEventModifierFlags::empty(), 0.0, 0) + } else { + let modifier_flags = NSEvent::modifierFlags(current_event); + let timestamp = NSEvent::timestamp(current_event); + let event_number = NSEvent::eventNumber(current_event); + (modifier_flags, timestamp, event_number) + }; + + NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( + nil, + NSEventType::NSLeftMouseDown, + window_location, + modifier_flags, + timestamp, + target_window_number, + nil, + event_number, + 1, + 1.0, + ) + }; + + if drag_event.is_null() { + return Err(anyhow!("failed to create panel drag event")); + } + let _: () = msg_send![ns_window, performWindowDragWithEvent: drag_event]; + } + + Ok(()) +} + +fn panel_drag_local_coordinates( + client_x: f64, + client_y: f64, + content_width: f64, + content_height: f64, +) -> Option<(f64, f64)> { + if !client_x.is_finite() + || !client_y.is_finite() + || !content_width.is_finite() + || !content_height.is_finite() + || content_width <= 0.0 + || content_height <= 0.0 + { + return None; + } + Some(( + client_x.clamp(0.0, content_width), + (content_height - client_y).clamp(0.0, content_height), + )) +} + +fn is_panel_mouse_down_event( + event_type: u64, + event_window_number: i64, + target_window_number: i64, +) -> bool { + event_type == 1 && event_window_number == target_window_number +} + +#[cfg(test)] +mod tests { + use super::{is_panel_mouse_down_event, panel_drag_local_coordinates}; + + #[test] + fn panel_drag_reuses_only_the_target_windows_mouse_down_event() { + assert!(is_panel_mouse_down_event(1, 42, 42)); + assert!(!is_panel_mouse_down_event(1, 7, 42)); + assert!(!is_panel_mouse_down_event(0x15, 42, 42)); + assert!(!is_panel_mouse_down_event(6, 42, 42)); + } + + #[test] + fn panel_drag_fallback_preserves_the_original_webview_anchor() { + assert_eq!( + panel_drag_local_coordinates(24.0, 18.0, 240.0, 530.0), + Some((24.0, 512.0)) + ); + assert_eq!( + panel_drag_local_coordinates(-10.0, 900.0, 240.0, 530.0), + Some((0.0, 0.0)) + ); + assert_eq!( + panel_drag_local_coordinates(f64::NAN, 18.0, 240.0, 530.0), + None + ); + } +} diff --git a/src-tauri/src/commands/layout/settings.rs b/src-tauri/src/commands/layout/settings.rs index c01c27f4..6095e047 100644 --- a/src-tauri/src/commands/layout/settings.rs +++ b/src-tauri/src/commands/layout/settings.rs @@ -1,6 +1,7 @@ use tauri::{AppHandle, State}; use crate::{ + commands::editor::state::emit_best_effort, errors::CmdResult, models::{SettingsPatchInput, SettingsState}, state::AppState, @@ -28,11 +29,22 @@ pub fn settings_update( } else { None }; - let diff = state.settings.apply_patch(patch)?; + let transaction = state.store.commit_history_overlap_mutation(|store| { + Ok(crate::services::settings::apply_patch_to_store( + store, &patch, + )) + })?; if let Some(previous) = previous.as_ref() { state.resync_global_css_watcher(previous, &state.store.snapshot()); } drop(operation_guard); - state.emit_settings_changed(&diff, &app)?; - Ok(diff.full.unwrap_or_else(|| state.settings.snapshot())) + if let Some(status) = transaction.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + state.emit_settings_changed(&transaction.value, &app)?; + Ok(transaction + .value + .full + .clone() + .unwrap_or_else(|| state.settings.snapshot())) } diff --git a/src-tauri/src/commands/plugin/instances.rs b/src-tauri/src/commands/plugin/instances.rs new file mode 100644 index 00000000..cc391da7 --- /dev/null +++ b/src-tauri/src/commands/plugin/instances.rs @@ -0,0 +1,310 @@ +use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; + +use crate::{ + commands::editor::state::emit_best_effort, + models::{ + PluginInstancesChangedPayload, PluginInstancesCommitRequest, PluginInstancesCommitResult, + PluginInstancesReconcileRequest, PluginInstancesSnapshot, + }, + state::{plugin::PluginRpcRouter, store::AdmittedPluginInstancesCommit, AppState}, +}; + +const MAIN_WINDOW_LABEL: &str = "main"; +const PANEL_WINDOW_LABEL: &str = "panel"; + +fn plugin_mutation_source<'a>( + router: &PluginRpcRouter, + authority_generation: u64, + rpc_request_id: Option<&str>, + direct_window_label: &'a str, +) -> Result<&'a str, String> { + let Some(request_id) = rpc_request_id else { + return Ok(direct_window_label); + }; + if uuid::Uuid::parse_str(request_id).is_err() + || !router.has_pending_request( + request_id, + PANEL_WINDOW_LABEL, + MAIN_WINDOW_LABEL, + authority_generation, + ) + { + return Err("PLUGIN_RPC_REQUEST_NOT_FOUND".to_string()); + } + Ok(PANEL_WINDOW_LABEL) +} + +pub(crate) trait PluginInstancesEventEmitter { + fn main_available(&self) -> bool; + fn emit_plugin_instances_changed( + &self, + payload: &PluginInstancesChangedPayload, + ) -> Result<(), String>; +} + +impl PluginInstancesEventEmitter for AppHandle { + fn main_available(&self) -> bool { + self.get_webview_window(MAIN_WINDOW_LABEL).is_some() + } + + fn emit_plugin_instances_changed( + &self, + payload: &PluginInstancesChangedPayload, + ) -> Result<(), String> { + self.emit_to(MAIN_WINDOW_LABEL, "pluginInstances:changed", payload) + .map_err(|error| error.to_string()) + } +} + +pub(crate) fn publish_plugin_instances_changed( + emitter: &dyn PluginInstancesEventEmitter, + payload: &PluginInstancesChangedPayload, +) -> bool { + if !emitter.main_available() { + return false; + } + if let Err(error) = emitter.emit_plugin_instances_changed(payload) { + log::warn!("failed to publish plugin instance projection: {error}"); + return false; + } + true +} + +fn finish_plugin_instances_commit( + app: &AppHandle, + mutation_id: String, + authority_generation: u64, + committed: AdmittedPluginInstancesCommit, +) -> PluginInstancesCommitResult { + let outcome = &committed.outcome; + if outcome.changed { + let payload = PluginInstancesChangedPayload { + plugin_id: outcome.plugin_id.clone(), + revision: outcome.model_revision, + origin_mutation_id: Some(mutation_id), + }; + publish_plugin_instances_changed(app, &payload); + } + if let Some(status) = outcome.history_status.as_ref() { + emit_best_effort(app, "history:status", status); + } + + PluginInstancesCommitResult { + plugin_id: outcome.plugin_id.clone(), + model_revision: outcome.model_revision, + authority_generation, + changed: outcome.changed, + } +} + +#[tauri::command] +pub fn plugin_instances_get( + state: State<'_, AppState>, + plugin_id: String, +) -> Result { + let (instances, model_revision) = state.store.plugin_instances_get(&plugin_id)?; + let snapshot = PluginInstancesSnapshot { + plugin_id, + instances, + model_revision, + authority_generation: state.plugin_authority().generation(), + }; + if serde_json::to_vec(&snapshot) + .map_err(|error| format!("INVALID_PLUGIN_INSTANCES_SNAPSHOT:{error}"))? + .len() + > crate::state::plugin::MAX_PLUGIN_INSTANCES_REQUEST_BYTES + { + return Err("PLUGIN_INSTANCES_SNAPSHOT_TOO_LARGE".to_string()); + } + Ok(snapshot) +} + +#[tauri::command] +pub fn plugin_instances_commit( + state: State<'_, AppState>, + app: AppHandle, + window: WebviewWindow, + request: PluginInstancesCommitRequest, + rpc_request_id: Option, +) -> Result { + crate::state::plugin::validate_plugin_instances_request(&request)?; + if window.label() != MAIN_WINDOW_LABEL { + return Err("PLUGIN_INSTANCE_MUTATION_NOT_ALLOWED".to_string()); + } + let authority = state + .plugin_authority() + .admit(request.authority_generation)?; + let source_window_label = plugin_mutation_source( + state.plugin_rpc_router(), + authority.generation(), + rpc_request_id.as_deref(), + window.label(), + )?; + let admission = state.admit_frontend_history_mutation(source_window_label)?; + let mutation_id = request.mutation_id.clone(); + let committed = state + .store + .commit_plugin_instances_with_admission(request, admission)?; + Ok(finish_plugin_instances_commit( + &app, + mutation_id, + authority.generation(), + committed, + )) +} + +#[tauri::command] +pub fn plugin_instances_reconcile( + state: State<'_, AppState>, + app: AppHandle, + window: WebviewWindow, + request: PluginInstancesReconcileRequest, +) -> Result { + crate::state::plugin::validate_plugin_instances_reconcile_request(&request)?; + if window.label() != MAIN_WINDOW_LABEL { + return Err("PLUGIN_INSTANCE_MUTATION_NOT_ALLOWED".to_string()); + } + let authority = state + .plugin_authority() + .admit(request.authority_generation)?; + let admission = state.admit_frontend_history_mutation(window.label())?; + let mutation_id = request.mutation_id.clone(); + let committed = state + .store + .reconcile_plugin_instances_with_admission(request, admission)?; + Ok(finish_plugin_instances_commit( + &app, + mutation_id, + authority.generation(), + committed, + )) +} + +#[cfg(test)] +mod tests { + use parking_lot::Mutex; + + use super::*; + use crate::models::{PluginRpcRequestEnvelope, PluginRpcResponse}; + + struct TestEmitter { + main_available: bool, + events: Mutex>, + } + + impl PluginInstancesEventEmitter for TestEmitter { + fn main_available(&self) -> bool { + self.main_available + } + + fn emit_plugin_instances_changed( + &self, + payload: &PluginInstancesChangedPayload, + ) -> Result<(), String> { + self.events.lock().push(payload.clone()); + Ok(()) + } + } + + #[test] + fn missing_main_leaves_plugin_projection_pending_without_failure() { + let emitter = TestEmitter { + main_available: false, + events: Mutex::new(Vec::new()), + }; + let payload = PluginInstancesChangedPayload { + plugin_id: "demo".to_string(), + revision: 3, + origin_mutation_id: None, + }; + + assert!(!publish_plugin_instances_changed(&emitter, &payload)); + assert!(emitter.events.lock().is_empty()); + } + + #[test] + fn changed_payload_serializes_origin_for_commit_and_omits_it_for_history() { + let committed = PluginInstancesChangedPayload { + plugin_id: "demo".to_string(), + revision: 4, + origin_mutation_id: Some("mutation-1".to_string()), + }; + assert_eq!( + serde_json::to_value(committed).unwrap(), + serde_json::json!({ + "pluginId": "demo", + "revision": 4, + "originMutationId": "mutation-1", + }) + ); + + let restored = PluginInstancesChangedPayload { + plugin_id: "demo".to_string(), + revision: 5, + origin_mutation_id: None, + }; + assert_eq!( + serde_json::to_value(restored).unwrap(), + serde_json::json!({ "pluginId": "demo", "revision": 5 }) + ); + } + + #[test] + fn routed_commit_uses_pending_panel_provenance_until_response() { + let router = PluginRpcRouter::default(); + let request_id = "00000000-0000-0000-0000-000000000001".to_string(); + let envelope = PluginRpcRequestEnvelope { + protocol_version: 1, + request_id: request_id.clone(), + source_window_label: PANEL_WINDOW_LABEL.to_string(), + authority_generation: 7, + expected_model_revision: 3, + operation: "elements:delete".to_string(), + payload: serde_json::json!({ "fullIds": ["demo:one"] }), + }; + router + .forward_request(MAIN_WINDOW_LABEL, envelope, 3, |_, _| Ok(())) + .unwrap(); + + assert_eq!( + plugin_mutation_source(&router, 7, Some(&request_id), MAIN_WINDOW_LABEL).unwrap(), + PANEL_WINDOW_LABEL + ); + assert_eq!( + plugin_mutation_source(&router, 7, Some(&request_id), MAIN_WINDOW_LABEL).unwrap(), + PANEL_WINDOW_LABEL + ); + assert_eq!( + plugin_mutation_source(&router, 7, None, MAIN_WINDOW_LABEL).unwrap(), + MAIN_WINDOW_LABEL + ); + assert_eq!( + plugin_mutation_source( + &router, + 7, + Some("00000000-0000-0000-0000-000000000002"), + MAIN_WINDOW_LABEL, + ) + .unwrap_err(), + "PLUGIN_RPC_REQUEST_NOT_FOUND" + ); + + let response = PluginRpcResponse { + protocol_version: 1, + request_id: request_id.clone(), + authority_generation: 7, + model_revision: 4, + ok: true, + payload: None, + error: None, + }; + router + .forward_response(MAIN_WINDOW_LABEL, &response, |_, _| Ok(())) + .unwrap(); + + assert_eq!( + plugin_mutation_source(&router, 7, Some(&request_id), MAIN_WINDOW_LABEL).unwrap_err(), + "PLUGIN_RPC_REQUEST_NOT_FOUND" + ); + } +} diff --git a/src-tauri/src/commands/plugin/mod.rs b/src-tauri/src/commands/plugin/mod.rs index aa9e4cfb..2de80af4 100644 --- a/src-tauri/src/commands/plugin/mod.rs +++ b/src-tauri/src/commands/plugin/mod.rs @@ -1,2 +1,4 @@ pub mod bridge; +pub mod instances; +pub mod rpc; pub mod storage; diff --git a/src-tauri/src/commands/plugin/rpc.rs b/src-tauri/src/commands/plugin/rpc.rs new file mode 100644 index 00000000..d18b2283 --- /dev/null +++ b/src-tauri/src/commands/plugin/rpc.rs @@ -0,0 +1,115 @@ +use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; + +use crate::{ + models::{ + PluginAuthoritySnapshot, PluginRpcRequest, PluginRpcRequestEnvelope, PluginRpcResponse, + }, + state::{plugin::MAX_PLUGIN_RPC_BYTES, AppState}, +}; + +const MAIN_WINDOW_LABEL: &str = "main"; +const PANEL_WINDOW_LABEL: &str = "panel"; + +#[tauri::command] +pub fn plugin_rpc_send( + state: State<'_, AppState>, + app: AppHandle, + window: WebviewWindow, + target_window_label: String, + request: PluginRpcRequest, +) -> Result<(), String> { + crate::state::plugin::validate_plugin_rpc_request(&request)?; + if window.label() != PANEL_WINDOW_LABEL { + return Err("PLUGIN_RPC_SOURCE_NOT_ALLOWED".to_string()); + } + if target_window_label != MAIN_WINDOW_LABEL { + return Err("PLUGIN_RPC_TARGET_NOT_ALLOWED".to_string()); + } + if app.get_webview_window(MAIN_WINDOW_LABEL).is_none() { + return Err("AUTHORITY_UNAVAILABLE".to_string()); + } + + let _history_admission = state.admit_frontend_history_mutation(window.label())?; + let authority = state + .plugin_authority() + .admit(request.authority_generation)?; + let current_model_revision = state.store.plugin_model_revision(); + let envelope = PluginRpcRequestEnvelope { + protocol_version: request.protocol_version, + request_id: request.request_id, + source_window_label: window.label().to_string(), + authority_generation: authority.generation(), + expected_model_revision: request.expected_model_revision, + operation: request.operation, + payload: request.payload, + }; + if serde_json::to_vec(&envelope) + .map_err(|error| format!("INVALID_PLUGIN_RPC_REQUEST:{error}"))? + .len() + > MAX_PLUGIN_RPC_BYTES + { + return Err("PLUGIN_RPC_REQUEST_TOO_LARGE".to_string()); + } + + state.plugin_rpc_router().forward_request( + &target_window_label, + envelope, + current_model_revision, + |target, payload| { + if app.get_webview_window(target).is_none() { + return Err("AUTHORITY_UNAVAILABLE".to_string()); + } + app.emit_to(target, "plugin-rpc:request", payload) + .map_err(|error| error.to_string()) + }, + )?; + Ok(()) +} + +#[tauri::command] +pub fn plugin_rpc_respond( + state: State<'_, AppState>, + app: AppHandle, + window: WebviewWindow, + response: PluginRpcResponse, +) -> Result<(), String> { + crate::state::plugin::validate_plugin_rpc_response(&response)?; + if window.label() != MAIN_WINDOW_LABEL { + return Err("PLUGIN_RPC_RESPONDER_NOT_ALLOWED".to_string()); + } + let _authority = state + .plugin_authority() + .admit(response.authority_generation)?; + if response.model_revision != state.store.plugin_model_revision() { + return Err("PLUGIN_MODEL_REVISION_MISMATCH".to_string()); + } + state + .plugin_rpc_router() + .forward_response(window.label(), &response, |target, payload| { + if app.get_webview_window(target).is_none() { + return Err("PLUGIN_RPC_SOURCE_UNAVAILABLE".to_string()); + } + app.emit_to(target, "plugin-rpc:response", payload) + .map_err(|error| error.to_string()) + }) +} + +#[tauri::command] +pub fn plugin_authority_reset( + state: State<'_, AppState>, + app: AppHandle, + window: WebviewWindow, +) -> Result { + if window.label() != MAIN_WINDOW_LABEL { + return Err("PLUGIN_AUTHORITY_RESET_NOT_ALLOWED".to_string()); + } + let authority = state.reset_plugin_authority()?; + let snapshot = PluginAuthoritySnapshot { + authority_generation: authority.generation(), + model_revision: state.store.plugin_model_revision(), + }; + if let Err(error) = app.emit("plugin-rpc:authority-changed", &snapshot) { + log::warn!("failed to publish plugin authority generation: {error}"); + } + Ok(snapshot) +} diff --git a/src-tauri/src/commands/plugin/storage.rs b/src-tauri/src/commands/plugin/storage.rs index 14053b6c..d3de7f3f 100644 --- a/src-tauri/src/commands/plugin/storage.rs +++ b/src-tauri/src/commands/plugin/storage.rs @@ -1,14 +1,37 @@ use serde_json::Value; -use tauri::State; - -use crate::errors::CmdResult; -use crate::state::AppState; +use tauri::{AppHandle, State}; + +use crate::{ + commands::{ + editor::state::emit_best_effort, + plugin::instances::{publish_plugin_instances_changed, PluginInstancesEventEmitter}, + }, + errors::CmdResult, + models::PluginInstancesChangedPayload, + state::{store::PluginInstancesStorageChange, AppState}, +}; /// 플러그인 스토리지 키 생성 (네임스페이스 자동 적용) fn make_storage_key(key: &str) -> String { format!("plugin_data_{}", key) } +fn publish_plugin_instances_deletions( + emitter: &dyn PluginInstancesEventEmitter, + changes: &[PluginInstancesStorageChange], +) { + for change in changes { + publish_plugin_instances_changed( + emitter, + &PluginInstancesChangedPayload { + plugin_id: change.plugin_id.clone(), + revision: change.revision, + origin_mutation_id: None, + }, + ); + } +} + /// 플러그인 데이터 조회 #[tauri::command] pub fn plugin_storage_get(state: State<'_, AppState>, key: String) -> CmdResult> { @@ -18,22 +41,44 @@ pub fn plugin_storage_get(state: State<'_, AppState>, key: String) -> CmdResult< /// 플러그인 데이터 저장 #[tauri::command] -pub fn plugin_storage_set(state: State<'_, AppState>, key: String, value: Value) -> CmdResult<()> { +pub fn plugin_storage_set( + state: State<'_, AppState>, + app: AppHandle, + key: String, + value: Value, +) -> CmdResult<()> { let storage_key = make_storage_key(&key); - Ok(state.store.set_plugin_data(&storage_key, value)?) + let mutation = state.store.set_plugin_data(&storage_key, value)?; + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(()) } /// 플러그인 데이터 삭제 #[tauri::command] -pub fn plugin_storage_remove(state: State<'_, AppState>, key: String) -> CmdResult<()> { +pub fn plugin_storage_remove( + state: State<'_, AppState>, + app: AppHandle, + key: String, +) -> CmdResult<()> { let storage_key = make_storage_key(&key); - Ok(state.store.remove_plugin_data(&storage_key)?) + let mutation = state.store.remove_plugin_data(&storage_key)?; + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(()) } /// 모든 플러그인 데이터 삭제 #[tauri::command] -pub fn plugin_storage_clear(state: State<'_, AppState>) -> CmdResult<()> { - Ok(state.store.clear_all_plugin_data()?) +pub fn plugin_storage_clear(state: State<'_, AppState>, app: AppHandle) -> CmdResult<()> { + let mutation = state.store.clear_all_plugin_data()?; + publish_plugin_instances_deletions(&app, &mutation.plugin_instances_changes); + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(()) } /// 플러그인 데이터 키 목록 조회 @@ -67,20 +112,73 @@ pub fn plugin_storage_has_data(state: State<'_, AppState>, prefix: String) -> Cm #[tauri::command] pub fn plugin_storage_clear_by_prefix( state: State<'_, AppState>, + app: AppHandle, prefix: String, ) -> CmdResult { - let all_keys = state.store.get_all_plugin_keys()?; - let storage_prefix = format!("plugin_data_{}", prefix); - let keys_to_remove: Vec = all_keys - .into_iter() - .filter(|k| k.starts_with(&storage_prefix)) - .collect(); + let mutation = state.store.remove_plugin_data_by_prefix(&storage_prefix)?; + publish_plugin_instances_deletions(&app, &mutation.plugin_instances_changes); + if let Some(status) = mutation.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + Ok(mutation.value) +} - let count = keys_to_remove.len(); - for key in keys_to_remove { - state.store.remove_plugin_data(&key)?; +#[cfg(test)] +mod tests { + use parking_lot::Mutex; + + use super::*; + + #[derive(Default)] + struct TestEmitter { + events: Mutex>, } - Ok(count) + impl PluginInstancesEventEmitter for TestEmitter { + fn main_available(&self) -> bool { + true + } + + fn emit_plugin_instances_changed( + &self, + payload: &PluginInstancesChangedPayload, + ) -> Result<(), String> { + self.events.lock().push(payload.clone()); + Ok(()) + } + } + + #[test] + fn bulk_deletion_publishes_each_plugin_revision_without_origin() { + let emitter = TestEmitter::default(); + let changes = vec![ + PluginInstancesStorageChange { + plugin_id: "alpha".to_string(), + revision: 7, + }, + PluginInstancesStorageChange { + plugin_id: "beta".to_string(), + revision: 7, + }, + ]; + + publish_plugin_instances_deletions(&emitter, &changes); + + assert_eq!( + *emitter.events.lock(), + vec![ + PluginInstancesChangedPayload { + plugin_id: "alpha".to_string(), + revision: 7, + origin_mutation_id: None, + }, + PluginInstancesChangedPayload { + plugin_id: "beta".to_string(), + revision: 7, + origin_mutation_id: None, + }, + ] + ); + } } diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index ca5478ef..899b55e8 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -12,17 +12,17 @@ use uuid::Uuid; use crate::{ commands::editor::{ css::TabCssResponse, - state::{emit_best_effort, publish_editor_change}, + state::{emit_best_effort, publish_editor_change_after_key_runtime}, }, custom_css::validate_css_path, defaults::{default_keys, default_positions}, errors::{CmdResult, CommandError}, models::{ - AppStoreData, CommittedEditorChange, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, - EditorCommitOrigin, EditorField, FontSettings, FontType, GradientSpec, GraphPositions, - KeyMappings, KeyPosition, KeyPositions, KnobPositions, LayerGroups, NoteSettings, - NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCss, TabCssOverrides, - TabNoteSettings, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + AppStoreData, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, EditorCommitOrigin, + EditorField, FontSettings, FontType, GradientSpec, GraphPositions, KeyMappings, + KeyPosition, KeyPositions, KnobPositions, LayerGroups, NoteSettings, NoteSettingsPatch, + SettingsPatchInput, StatPositions, TabCss, TabCssOverrides, TabNoteSettings, + SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }, services::settings::apply_patch_to_store, state::AppState, @@ -35,23 +35,6 @@ use super::{ PRESET_LOCAL_SOUND_PREFIX, }; -fn apply_editor_runtime_best_effort( - state: &AppState, - app: &AppHandle, - change: &CommittedEditorChange, -) { - if let Err(error) = state.apply_committed_editor_key_runtime( - app, - &change.document.keys, - &change.selected_key_type, - &change.key_counters, - ) { - log::error!("[Preset] failed to publish committed key counters: {error:#}"); - } - state.obs_broadcast_counters(); - state.refresh_obs_snapshot(); -} - fn read_preset_file(path: &Path) -> CmdResult { let content = fs::read_to_string(path)?; let value: serde_json::Value = @@ -332,9 +315,11 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, #[serde(skip_serializing_if = "Option::is_none")] pub field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_history_epoch: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -86,6 +90,27 @@ impl EditorCommitError { retryable: true, } } + + pub fn history_in_progress() -> Self { + Self { + error_code: EditorCommitErrorCode::HistoryInProgress, + message: "history operation is in progress".to_string(), + details: None, + retryable: true, + } + } + + pub fn history_epoch_conflict(current_history_epoch: u64) -> Self { + Self { + error_code: EditorCommitErrorCode::HistoryEpochConflict, + message: "history epoch conflict".to_string(), + details: Some(EditorCommitErrorDetails { + current_history_epoch: Some(current_history_epoch), + ..EditorCommitErrorDetails::default() + }), + retryable: true, + } + } } impl std::fmt::Display for EditorCommitError { diff --git a/src-tauri/src/keyboard/manager.rs b/src-tauri/src/keyboard/manager.rs index c0d6593a..07814d73 100644 --- a/src-tauri/src/keyboard/manager.rs +++ b/src-tauri/src/keyboard/manager.rs @@ -74,6 +74,18 @@ impl KeyboardManager { mode_exists } + pub fn update_mappings(&self, mappings: KeyMappings) { + let mut mappings_guard = self.mappings.write(); + let current_mode = self.current_mode.read(); + let next_valid_keys = Self::valid_keys_for_mode(&mappings, ¤t_mode); + let mut valid_keys = self.valid_keys.write(); + let mut active_keys = self.active_keys.write(); + + Self::retain_active_keys(&mut active_keys, ¤t_mode, &next_valid_keys); + *mappings_guard = mappings; + *valid_keys = next_valid_keys; + } + pub fn current_mode(&self) -> String { self.current_mode.read().clone() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1124983..5d21f0e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,16 @@ pub fn compute_compensating_zoom() -> f64 { } } +fn should_apply_zoom_for_platform(zoom: f64, is_macos: bool) -> bool { + !is_macos || (zoom - 1.0).abs() >= f64::EPSILON +} + +/// macOS WKWebView의 identity zoom은 선택·캐럿을 리셋하므로 생략 +/// Windows는 이전 보정값에서 1.0으로 복귀할 수 있어 항상 적용 +pub fn should_apply_compensating_zoom(zoom: f64) -> bool { + should_apply_zoom_for_platform(zoom, cfg!(target_os = "macos")) +} + /// Windows 레지스트리에서 접근성 텍스트 크기 비율을 읽습니다. /// 레지스트리 키: HKCU\SOFTWARE\Microsoft\Accessibility\TextScaleFactor (DWORD, 100~225) /// 읽기에 실패하거나 설정이 없으면 1.0(100%)을 반환합니다. @@ -87,3 +97,20 @@ fn get_windows_text_scale_factor() -> f64 { } } } + +#[cfg(test)] +mod tests { + use super::should_apply_zoom_for_platform; + + #[test] + fn macos_skips_only_identity_zoom() { + assert!(!should_apply_zoom_for_platform(1.0, true)); + assert!(should_apply_zoom_for_platform(0.8, true)); + } + + #[test] + fn windows_applies_identity_zoom_after_a_previous_compensation() { + assert!(should_apply_zoom_for_platform(0.8, false)); + assert!(should_apply_zoom_for_platform(1.0, false)); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 98a3c3da..854cc0e2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -27,9 +27,9 @@ use tauri::{ Position, }; -use dm_note::compute_compensating_zoom; +use dm_note::{compute_compensating_zoom, should_apply_compensating_zoom}; -use state::{AppState, AppStore}; +use state::{AppState, AppStore, PANEL_LABEL}; fn main() { #[cfg(target_os = "windows")] @@ -80,6 +80,11 @@ fn main() { .on_page_load(|webview, payload| { if matches!(payload.event(), PageLoadEvent::Finished) { let zoom = compute_compensating_zoom(); + // macOS WKWebView의 identity zoom은 활성 입력 선택·캐럿을 리셋 + // Windows는 이전 보정값을 1.0으로 복구해야 하므로 호출 유지 + if !should_apply_compensating_zoom(zoom) { + return; + } let label = webview.label(); log::info!( "[zoom-guard] page loaded in '{label}', applying compensating zoom={zoom:.6}" @@ -139,6 +144,9 @@ fn main() { .map_err(|e| -> Box { e.into() })?; let app_state = AppState::initialize(store) .map_err(|e| -> Box { e.into() })?; + let preview_broker = + services::preview_broker::PreviewBroker::new(app_state.store.history_gate()); + app.manage(preview_broker); app.manage(app_state); let handle = app.handle(); { @@ -150,7 +158,11 @@ fn main() { configure_main_window(app.handle()); #[cfg(target_os = "macos")] - launch_macos_dock_helper(); + { + state::macos_termination::install(app.handle()) + .map_err(|error| -> Box { error.into() })?; + launch_macos_dock_helper(); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -205,13 +217,18 @@ fn main() { commands::editor::note_tab::note_tab_clear, commands::editor::state::editor_get, commands::editor::state::editor_commit, + commands::editor::history::history_status, + commands::editor::history::history_undo, + commands::editor::history::history_redo, + commands::editor::preview::editor_preview_subscribe, + commands::editor::preview::editor_preview_publish, + commands::editor::preview::editor_preview_cancel, + commands::editor::selection::selection_session_get, + commands::editor::selection::selection_session_publish, // 키 입력/설정 commands::keys::keys::keys_get, commands::keys::keys::keys_get_counters, commands::keys::keys::positions_get, - commands::keys::keys::keys_update, - commands::keys::keys::keys_update_with_positions, - commands::keys::keys::positions_update, commands::keys::keys::keys_set_mode, commands::keys::keys::keys_reset_all, commands::keys::keys::keys_reset_mode, @@ -226,9 +243,7 @@ fn main() { commands::keys::keys::custom_tabs_delete, commands::keys::keys::custom_tabs_select, commands::keys::keys::custom_tabs_restore, - commands::keys::keys::editor_history_restore, commands::keys::keys::layer_groups_get, - commands::keys::keys::layer_groups_update, commands::keys::key_sound::key_sound_get_status, commands::keys::key_sound::key_sound_set_enabled, commands::keys::key_sound::key_sound_set_volume, @@ -262,6 +277,12 @@ fn main() { commands::layout::overlay::overlay_set_lock, commands::layout::overlay::overlay_set_anchor, commands::layout::overlay::overlay_resize, + commands::layout::panel::panel_window_show, + commands::layout::panel::panel_window_close, + commands::layout::panel::panel_window_take_view_state, + commands::layout::panel::panel_window_close_ack, + commands::layout::panel::panel_window_is_open, + commands::layout::panel::panel_window_start_dragging, // 미디어 commands::media::image::image_load, commands::media::counter_animation::counter_animation_list, @@ -276,6 +297,12 @@ fn main() { // 플러그인 commands::plugin::bridge::plugin_bridge_send, commands::plugin::bridge::plugin_bridge_send_to, + commands::plugin::rpc::plugin_rpc_send, + commands::plugin::rpc::plugin_rpc_respond, + commands::plugin::rpc::plugin_authority_reset, + commands::plugin::instances::plugin_instances_commit, + commands::plugin::instances::plugin_instances_get, + commands::plugin::instances::plugin_instances_reconcile, commands::plugin::storage::plugin_storage_get, commands::plugin::storage::plugin_storage_set, commands::plugin::storage::plugin_storage_remove, @@ -288,21 +315,40 @@ fn main() { .expect("error while building tauri application"); app.run(|app_handle, event| match event { - tauri::RunEvent::ExitRequested { code, api, .. } if code.is_none() => { - api.prevent_exit(); - if let Some(state) = app_handle.try_state::() { - if let Some(main) = app_handle.get_webview_window("main") { - let _ = main.hide(); + tauri::RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::Destroyed, + .. + } => { + if label == "main" { + if let Some(state) = app_handle.try_state::() { + state.mark_plugin_authority_unavailable(); } - if let Some(overlay) = app_handle.get_webview_window("overlay") { - let _ = overlay.hide(); + } + if label == PANEL_LABEL { + if let Some(state) = app_handle.try_state::() { + state.handle_panel_window_destroyed(app_handle); } - state.request_frontend_shutdown(app_handle.clone()); + } + if let Some(broker) = app_handle.try_state::() + { + broker.remove_label(&label); } } - tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { + tauri::RunEvent::ExitRequested { api, .. } => { + if let Some(state) = app_handle.try_state::() { + if !state.is_process_exit_authorized() { + api.prevent_exit(); + state.request_frontend_shutdown(app_handle.clone()); + } + } + } + tauri::RunEvent::Exit => { if let Some(state) = app_handle.try_state::() { state.arm_shutdown_watchdog("RunEvent state shutdown"); + if let Err(err) = state.capture_and_flush_panel_bounds_for_lifecycle(app_handle) { + log::warn!("failed to persist panel bounds during RunEvent exit: {err}"); + } state.shutdown(); state.set_shutdown_watchdog_stage("RunEvent process exit"); } @@ -525,8 +571,10 @@ fn apply_main_window_configuration( // Windows 접근성 텍스트 크기 설정에 의한 WebView2 스케일링을 보상 let zoom = compute_compensating_zoom(); log::info!("[zoom-guard] main window initial config: compensating zoom={zoom:.6}"); - if let Err(err) = window.set_zoom(zoom) { - log::warn!("failed to set main window compensating zoom: {err}"); + if should_apply_compensating_zoom(zoom) { + if let Err(err) = window.set_zoom(zoom) { + log::warn!("failed to set main window compensating zoom: {err}"); + } } let state = app.state::(); @@ -567,8 +615,8 @@ fn register_dev_capability(app: &tauri::App) -> Result<(), Box, - pub selected_key_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_counters: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub settings_patch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tab_note_overrides: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum EditorField { @@ -201,6 +186,8 @@ impl EditorPatchV1 { pub struct EditorCommitRequest { pub base_revision: u64, pub mutation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gesture_id: Option, pub changes: EditorPatchV1, } @@ -218,12 +205,33 @@ pub struct EditorGetResult { pub document: EditorDocumentV1, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryTruncated { + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryStatus { + pub history_revision: u64, + pub history_epoch: u64, + pub status_seq: u64, + pub can_undo: bool, + pub can_redo: bool, + pub busy: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub truncated: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorCommittedV1 { pub schema_version: u16, pub revision: u64, pub mutation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gesture_id: Option, pub origin: String, pub changed_fields: Vec, pub patch: EditorPatchV1, @@ -233,6 +241,8 @@ pub struct EditorCommittedV1 { pub enum EditorCommitOrigin { StrictEditorCommit, LegacyAdapter(String), + HistoryUndo, + HistoryRedo, // 이벤트 없는 부팅 복구용 origin 계약 #[allow(dead_code)] LoadRecovery, @@ -243,6 +253,8 @@ impl EditorCommitOrigin { match self { Self::StrictEditorCommit => Some("editorCommit".to_string()), Self::LegacyAdapter(command) => Some(format!("legacy:{command}")), + Self::HistoryUndo => Some("historyUndo".to_string()), + Self::HistoryRedo => Some("historyRedo".to_string()), Self::LoadRecovery => None, } } @@ -256,6 +268,8 @@ pub struct CommittedEditorChange { pub document: EditorDocumentV1, pub selected_key_type: String, pub key_counters: KeyCounters, + pub history_status: Option, + pub(crate) runtime_publication_generation: u64, } #[derive(Debug, Clone)] diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index a3b103ce..7759d109 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,7 +1,9 @@ pub mod editor; pub mod obs; +pub mod plugin; pub use editor::*; +pub use plugin::*; use serde::de::Error as DeError; use serde::ser::{Error as SerError, SerializeMap}; @@ -1660,6 +1662,14 @@ pub struct OverlayBounds { pub width: f64, pub height: f64, } + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PanelBounds { + pub x: f64, + pub y: f64, + pub height: f64, +} impl OverlayResizeAnchor { pub fn as_str(&self) -> &'static str { match self { @@ -1763,6 +1773,8 @@ pub struct AppStoreData { pub custom_js: CustomJs, pub overlay_resize_anchor: OverlayResizeAnchor, pub overlay_bounds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub panel_bounds: Option, pub overlay_last_content_top_offset: Option, #[serde(default)] pub overlay_bounds_are_logical: bool, @@ -1838,6 +1850,7 @@ impl Default for AppStoreData { custom_js: CustomJs::default(), overlay_resize_anchor: OverlayResizeAnchor::TopLeft, overlay_bounds: None, + panel_bounds: None, overlay_last_content_top_offset: None, overlay_bounds_are_logical: false, key_counter_enabled: false, diff --git a/src-tauri/src/models/plugin.rs b/src-tauri/src/models/plugin.rs new file mode 100644 index 00000000..aabc6858 --- /dev/null +++ b/src-tauri/src/models/plugin.rs @@ -0,0 +1,143 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginPoint { + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginSize { + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum PluginSettingValue { + String(String), + Number(f64), + Boolean(bool), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SavedPluginInstance { + pub position: PluginPoint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub measured_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginInstancesCommitRequest { + pub plugin_id: String, + pub instances: Vec, + pub mutation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gesture_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_history_epoch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_model_revision: Option, + pub authority_generation: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginInstancesReconcileRequest { + pub plugin_id: String, + pub valid_tab_ids: Vec, + pub mutation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_history_epoch: Option, + pub authority_generation: u64, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstancesSnapshot { + pub plugin_id: String, + pub instances: Vec, + pub model_revision: u64, + pub authority_generation: u64, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstancesCommitResult { + pub plugin_id: String, + pub model_revision: u64, + pub authority_generation: u64, + pub changed: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstancesChangedPayload { + pub plugin_id: String, + pub revision: u64, + // commit 발신 mutation id - undo/redo 복원 이벤트는 None (self-echo 구분용) + #[serde(skip_serializing_if = "Option::is_none")] + pub origin_mutation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginRpcRequest { + pub protocol_version: u32, + pub request_id: String, + pub authority_generation: u64, + pub expected_model_revision: u64, + pub operation: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginRpcRequestEnvelope { + pub protocol_version: u32, + pub request_id: String, + pub source_window_label: String, + pub authority_generation: u64, + pub expected_model_revision: u64, + pub operation: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginRpcError { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginRpcResponse { + pub protocol_version: u32, + pub request_id: String, + pub authority_generation: u64, + pub model_revision: u64, + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginAuthoritySnapshot { + pub authority_generation: u64, + pub model_revision: u64, +} diff --git a/src-tauri/src/services/css_watcher.rs b/src-tauri/src/services/css_watcher.rs index d973e09b..40fbac27 100644 --- a/src-tauri/src/services/css_watcher.rs +++ b/src-tauri/src/services/css_watcher.rs @@ -16,10 +16,11 @@ use parking_lot::RwLock; use tauri::{AppHandle, Manager}; use crate::commands::editor::{css::TabCssResponse, state::emit_best_effort}; -use crate::state::{AppState, AppStore}; +use crate::errors::EditorCommitError; +use crate::state::{store::AdmittedHistoryOverlapMutation, AppState, AppStore}; use crate::{ custom_css::{custom_css_settings_diff, validate_css_path, ValidatedCssFile}, - models::{AppStoreData, CustomCss, TabCss}, + models::{AppStoreData, CustomCss, TabCss, TabCssOverrides}, state::local_asset_path::path_identity_key, }; @@ -75,6 +76,30 @@ impl CssWatcher { self.unwatch_target(&CssWatchTarget::Tab(tab_id.to_string())); } + pub fn resync_tabs(&self, overrides: &TabCssOverrides) { + { + let mut watchers = self.watchers.write(); + watchers.retain(|_, entry| { + entry + .targets + .retain(|target| matches!(target, CssWatchTarget::Global)); + !entry.targets.is_empty() + }); + } + + for (tab_id, css) in overrides { + if !css.enabled { + continue; + } + let Some(path) = css.path.as_deref() else { + continue; + }; + if let Err(error) = self.watch_tab(path, tab_id) { + log::warn!("[CssWatcher] Failed to restore tab CSS watcher {tab_id}: {error}"); + } + } + } + /// 특정 경로에 대한 워칭 시작 fn watch_path(&self, path: &str, target: CssWatchTarget) -> Result<(), String> { let path_buf = std::fs::canonicalize(path) @@ -246,19 +271,18 @@ fn reload_css_consumers(store: &AppStore, app: &AppHandle, path: &str) -> Result error.detail ) })?; - let mut committed_global = None; - let mut committed_tabs = Vec::new(); - store - .update(|state| { - (committed_global, committed_tabs) = apply_reload_if_current(state, path, &loaded); - }) - .map_err(|error| error.to_string())?; + let transaction = commit_css_reload(store, path, &loaded).map_err(|error| error.to_string())?; + let (committed_global, committed_tabs) = &transaction.value; + + if let Some(status) = transaction.history_status.as_ref() { + emit_best_effort(app, "history:status", status); + } if let Some(css) = committed_global.as_ref() { emit_best_effort(app, "css:content", css); app_state.notify_obs_settings_diff(custom_css_settings_diff(&store.snapshot())); } - for (tab_id, css) in &committed_tabs { + for (tab_id, css) in committed_tabs { emit_best_effort( app, "tabCss:changed", @@ -282,7 +306,17 @@ fn reload_css_consumers(store: &AppStore, app: &AppHandle, path: &str) -> Result Ok(()) } -fn apply_reload_if_current( +pub(crate) type CssReloadChanges = (Option, Vec<(String, TabCss)>); + +pub(crate) fn commit_css_reload( + store: &AppStore, + path: &str, + loaded: &ValidatedCssFile, +) -> Result, EditorCommitError> { + store.commit_history_overlap_mutation(|state| Ok(apply_reload_if_current(state, path, loaded))) +} + +pub(crate) fn apply_reload_if_current( state: &mut AppStoreData, path: &str, loaded: &ValidatedCssFile, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5a53d589..3d4a2bdc 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ pub mod css_watcher; pub mod obs_bridge; +pub mod preview_broker; pub mod settings; diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 2c928245..6a94f2d0 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -1381,6 +1381,11 @@ mod tests { assert!(!is_allowed_command("settings_update")); assert!(!is_allowed_command("keys_update")); assert!(!is_allowed_command("keys_update_with_positions")); + assert!(!is_allowed_command("plugin_rpc_send")); + assert!(!is_allowed_command("plugin_rpc_respond")); + assert!(!is_allowed_command("plugin_instances_commit")); + assert!(!is_allowed_command("plugin_instances_reconcile")); + assert!(!is_allowed_command("plugin_authority_reset")); assert!(!is_allowed_command("app_bootstrap_extra")); assert!(!is_allowed_command("plugin:window|close")); assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs new file mode 100644 index 00000000..2934c805 --- /dev/null +++ b/src-tauri/src/services/preview_broker.rs @@ -0,0 +1,900 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::Arc, +}; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tauri::ipc::Channel; +use uuid::Uuid; + +use crate::state::history::{HistoryAdmission, HistoryAdmissionGate}; + +const PREVIEW_SCHEMA_VERSION: u16 = 1; +const MAX_PREVIEW_BYTES: usize = 64 * 1024; +const MAX_PREVIEW_TARGETS: usize = 512; +const TOMBSTONE_CAPACITY: usize = 1_024; +const MAX_ACTIVE_PREVIEW_SESSIONS: usize = TOMBSTONE_CAPACITY; + +// keyPositionSchema(src/types/key/keys.ts) 필드와 동기 유지 +// 제외: count(런타임 파생), layerName·groupId(식별자, 프리뷰 대상 아님) +const KEY_POSITION_PATCH_FIELDS: &[&str] = &[ + "dx", + "dy", + "width", + "height", + "hidden", + "activeImage", + "inactiveImage", + "soundEnabled", + "soundPath", + "soundVolume", + "activeTransparent", + "idleTransparent", + "noteColor", + "noteOpacity", + "noteOpacityTop", + "noteOpacityBottom", + "noteBorderRadius", + "noteWidth", + "noteAlignment", + "noteEffectEnabled", + "noteGlowEnabled", + "noteGlowSize", + "noteGlowOpacity", + "noteGlowOpacityTop", + "noteGlowOpacityBottom", + "noteGlowColor", + "noteAutoYCorrection", + "noteOffsetX", + "noteOffsetY", + "noteBorderWidth", + "noteBorderColor", + "noteBorderOpacity", + "noteBorderSide", + "className", + "zIndex", + "counter", + "backgroundColor", + "activeBackgroundColor", + "borderColor", + "activeBorderColor", + "backgroundGradient", + "activeBackgroundGradient", + "borderGradient", + "activeBorderGradient", + "borderWidth", + "borderRadius", + "shadow", + "activeShadow", + "fontSize", + "fontColor", + "activeFontColor", + "graphAnimationEnabled", + "fontFamily", + "idleImageFit", + "activeImageFit", + "imageFit", + "useInlineStyles", + "displayText", + "fontWeight", + "fontItalic", + "fontUnderline", + "fontStrikethrough", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewKind { + Patch, + Cancel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::enum_variant_names)] +pub enum PreviewDomain { + KeyPosition, + StatPosition, + GraphPosition, + KnobPosition, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PreviewEnvelope { + pub schema_version: u16, + pub session_id: String, + pub seq: u64, + pub kind: PreviewKind, + pub source_label: String, + pub domain: PreviewDomain, + pub mode: String, + pub targets: Vec, + pub patch: Map, + // cancel 전용: 수신측이 이 revision 반영 후에만 세션을 제거하는 순서 게이트 + #[serde(default)] + pub min_revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PreviewPublishRequest { + pub schema_version: u16, + pub session_id: String, + pub seq: u64, + #[serde(default = "patch_kind")] + pub kind: PreviewKind, + pub domain: PreviewDomain, + pub mode: String, + pub targets: Vec, + pub patch: Map, +} + +struct ChannelRegistration { + generation: u64, + channel: Channel, +} + +struct PreviewSession { + owner: String, + generation: u64, + last_seq: u64, +} + +#[derive(Default)] +struct BrokerState { + next_generation: u64, + channels: HashMap, + sessions: HashMap, + tombstones: HashSet, + tombstone_order: VecDeque, +} + +pub struct PreviewBroker { + state: Mutex, + history_gate: Arc, +} + +fn patch_kind() -> PreviewKind { + PreviewKind::Patch +} + +impl PreviewBroker { + #[allow(dead_code)] + pub(crate) fn new(history_gate: Arc) -> Self { + Self { + state: Mutex::new(BrokerState::default()), + history_gate, + } + } + + pub fn subscribe(&self, label: &str, channel: Channel) -> Result { + let admission = self.history_gate.try_admit()?; + self.subscribe_after_admission(label, channel, admission) + } + + fn subscribe_after_admission( + &self, + label: &str, + channel: Channel, + admission: HistoryAdmission, + ) -> Result { + let (generation, recipients, cancellations) = { + let mut state = self.state.lock(); + self.history_gate.revalidate(admission)?; + let generation = state + .next_generation + .checked_add(1) + .ok_or_else(|| "preview registration generation overflow".to_string())?; + state.next_generation = generation; + state.channels.insert( + label.to_string(), + ChannelRegistration { + generation, + channel, + }, + ); + + let cancellations = cancel_owned_sessions(&mut state, label, Some(generation)); + let recipients = clone_channels(&state, None); + (generation, recipients, cancellations) + }; + + send_envelopes(&recipients, &cancellations); + Ok(generation) + } + + pub fn publish(&self, label: &str, request: PreviewPublishRequest) -> Result<(), String> { + validate_publish_request(&request)?; + let envelope = PreviewEnvelope { + schema_version: request.schema_version, + session_id: request.session_id.clone(), + seq: request.seq, + kind: request.kind, + source_label: label.to_string(), + domain: request.domain, + mode: request.mode, + targets: request.targets, + patch: request.patch, + min_revision: None, + }; + validate_payload_size(&envelope)?; + + let admission = self.history_gate.try_admit()?; + self.publish_after_admission(label, envelope, admission) + } + + fn publish_after_admission( + &self, + label: &str, + envelope: PreviewEnvelope, + admission: HistoryAdmission, + ) -> Result<(), String> { + let recipients = { + let mut state = self.state.lock(); + self.history_gate.revalidate(admission)?; + if state.tombstones.contains(&envelope.session_id) { + return Err("preview session has already ended".to_string()); + } + + let generation = state + .channels + .get(label) + .map(|registration| registration.generation) + .ok_or_else(|| "preview publisher is not subscribed".to_string())?; + + match state.sessions.get_mut(&envelope.session_id) { + Some(session) => { + if session.owner != label { + return Err("preview session is owned by another window".to_string()); + } + if session.generation != generation { + return Err("preview session registration is stale".to_string()); + } + if envelope.seq <= session.last_seq { + return Err("preview sequence must increase monotonically".to_string()); + } + session.last_seq = envelope.seq; + } + None => { + if state.sessions.len() >= MAX_ACTIVE_PREVIEW_SESSIONS { + return Err(format!( + "active preview session count exceeds {MAX_ACTIVE_PREVIEW_SESSIONS}" + )); + } + state.sessions.insert( + envelope.session_id.clone(), + PreviewSession { + owner: label.to_string(), + generation, + last_seq: envelope.seq, + }, + ); + } + } + + clone_channels(&state, Some(label)) + }; + + send_envelopes(&recipients, &[envelope]); + Ok(()) + } + + pub(crate) fn cancel_all(&self) -> usize { + let (recipients, cancellations) = { + let mut state = self.state.lock(); + let session_ids = state.sessions.keys().cloned().collect::>(); + let cancellations = session_ids + .into_iter() + .filter_map(|session_id| { + let session = state.sessions.remove(&session_id)?; + insert_tombstone(&mut state, session_id.clone()); + Some(cancellation_envelope(&session_id, &session)) + }) + .collect::>(); + let recipients = clone_channels(&state, None); + (recipients, cancellations) + }; + let cancelled = cancellations.len(); + send_envelopes(&recipients, &cancellations); + cancelled + } + + pub fn cancel( + &self, + label: &str, + session_id: &str, + min_revision: Option, + ) -> Result<(), String> { + validate_session_id(session_id)?; + let (recipients, envelope) = { + let mut state = self.state.lock(); + if state.tombstones.contains(session_id) { + return Err("preview session has already ended".to_string()); + } + let session = match state.sessions.get(session_id) { + Some(session) => { + if session.owner != label { + return Err("preview session is owned by another window".to_string()); + } + state + .sessions + .remove(session_id) + .expect("preview session exists after ownership validation") + } + None => { + let generation = state + .channels + .get(label) + .map(|registration| registration.generation) + .ok_or_else(|| "preview canceller is not subscribed".to_string())?; + PreviewSession { + owner: label.to_string(), + generation, + last_seq: 0, + } + } + }; + insert_tombstone(&mut state, session_id.to_string()); + let recipients = clone_channels(&state, Some(label)); + let mut envelope = cancellation_envelope(session_id, &session); + envelope.min_revision = min_revision; + (recipients, envelope) + }; + + send_envelopes(&recipients, &[envelope]); + Ok(()) + } + + pub fn finish_committed_session( + &self, + label: &str, + session_id: &str, + committed_revision: Option, + ) -> Result { + if Uuid::parse_str(session_id).is_err() { + return Ok(false); + } + let (recipients, envelope) = { + let mut state = self.state.lock(); + if state.tombstones.contains(session_id) { + return Ok(false); + } + let session = match state.sessions.get(session_id) { + Some(session) => { + if session.owner != label { + return Err("preview session is owned by another window".to_string()); + } + state + .sessions + .remove(session_id) + .expect("preview session exists after ownership validation") + } + None => PreviewSession { + owner: label.to_string(), + generation: state + .channels + .get(label) + .map_or(0, |registration| registration.generation), + last_seq: 0, + }, + }; + insert_tombstone(&mut state, session_id.to_string()); + let recipients = clone_channels(&state, Some(label)); + let mut envelope = cancellation_envelope(session_id, &session); + // 수신측이 커밋 canonical 반영 후에만 세션을 제거하도록 게이트 + envelope.min_revision = committed_revision; + (recipients, envelope) + }; + + send_envelopes(&recipients, &[envelope]); + Ok(true) + } + + pub fn remove_label(&self, label: &str) { + let (recipients, cancellations) = { + let mut state = self.state.lock(); + state.channels.remove(label); + let cancellations = cancel_owned_sessions(&mut state, label, None); + let recipients = clone_channels(&state, None); + (recipients, cancellations) + }; + + send_envelopes(&recipients, &cancellations); + } +} + +#[cfg(test)] +impl Default for PreviewBroker { + fn default() -> Self { + Self::new(Arc::new(HistoryAdmissionGate::default())) + } +} + +fn validate_publish_request(request: &PreviewPublishRequest) -> Result<(), String> { + if request.schema_version != PREVIEW_SCHEMA_VERSION { + return Err("unsupported preview schema version".to_string()); + } + if request.kind != PreviewKind::Patch { + return Err("editor_preview_publish only accepts patch messages".to_string()); + } + validate_session_id(&request.session_id)?; + if request.targets.len() > MAX_PREVIEW_TARGETS { + return Err(format!( + "preview target count exceeds {MAX_PREVIEW_TARGETS}" + )); + } + if let Some(field) = request + .patch + .keys() + .find(|field| !KEY_POSITION_PATCH_FIELDS.contains(&field.as_str())) + { + return Err(format!("preview patch field '{field}' is not allowed")); + } + validate_payload_size(request) +} + +fn validate_session_id(session_id: &str) -> Result<(), String> { + Uuid::parse_str(session_id) + .map(|_| ()) + .map_err(|_| "preview sessionId must be a UUID".to_string()) +} + +fn validate_payload_size(payload: &impl Serialize) -> Result<(), String> { + let size = serde_json::to_vec(payload) + .map_err(|error| format!("failed to serialize preview payload: {error}"))? + .len(); + if size > MAX_PREVIEW_BYTES { + return Err(format!( + "preview payload exceeds the {MAX_PREVIEW_BYTES} byte limit" + )); + } + Ok(()) +} + +fn clone_channels( + state: &BrokerState, + excluded_label: Option<&str>, +) -> Vec> { + state + .channels + .iter() + .filter(|(label, _)| excluded_label != Some(label.as_str())) + .map(|(_, registration)| registration.channel.clone()) + .collect() +} + +fn cancel_owned_sessions( + state: &mut BrokerState, + label: &str, + current_generation: Option, +) -> Vec { + let session_ids: Vec = state + .sessions + .iter() + .filter(|(_, session)| { + session.owner == label && current_generation != Some(session.generation) + }) + .map(|(session_id, _)| session_id.clone()) + .collect(); + + session_ids + .into_iter() + .filter_map(|session_id| { + let session = state.sessions.remove(&session_id)?; + insert_tombstone(state, session_id.clone()); + Some(cancellation_envelope(&session_id, &session)) + }) + .collect() +} + +fn cancellation_envelope(session_id: &str, session: &PreviewSession) -> PreviewEnvelope { + PreviewEnvelope { + schema_version: PREVIEW_SCHEMA_VERSION, + session_id: session_id.to_string(), + seq: session.last_seq.saturating_add(1), + kind: PreviewKind::Cancel, + source_label: session.owner.clone(), + domain: PreviewDomain::KeyPosition, + mode: String::new(), + targets: Vec::new(), + patch: Map::new(), + min_revision: None, + } +} + +fn insert_tombstone(state: &mut BrokerState, session_id: String) { + if !state.tombstones.insert(session_id.clone()) { + return; + } + state.tombstone_order.push_back(session_id); + while state.tombstone_order.len() > TOMBSTONE_CAPACITY { + if let Some(expired) = state.tombstone_order.pop_front() { + state.tombstones.remove(&expired); + } + } +} + +fn send_envelopes(recipients: &[Channel], envelopes: &[PreviewEnvelope]) { + for envelope in envelopes { + for recipient in recipients { + if let Err(error) = recipient.send(envelope.clone()) { + log::warn!("failed to send editor preview: {error}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + use tauri::ipc::InvokeResponseBody; + + use super::*; + + fn session_id() -> String { + Uuid::new_v4().to_string() + } + + fn request(session_id: &str, seq: u64) -> PreviewPublishRequest { + request_for_domain(session_id, seq, PreviewDomain::KeyPosition) + } + + fn request_for_domain( + session_id: &str, + seq: u64, + domain: PreviewDomain, + ) -> PreviewPublishRequest { + PreviewPublishRequest { + schema_version: PREVIEW_SCHEMA_VERSION, + session_id: session_id.to_string(), + seq, + kind: PreviewKind::Patch, + domain, + mode: "default".to_string(), + targets: vec![0], + patch: Map::from_iter([("width".to_string(), Value::from(80))]), + } + } + + fn envelope(label: &str, request: PreviewPublishRequest) -> PreviewEnvelope { + PreviewEnvelope { + schema_version: request.schema_version, + session_id: request.session_id, + seq: request.seq, + kind: request.kind, + source_label: label.to_string(), + domain: request.domain, + mode: request.mode, + targets: request.targets, + patch: request.patch, + min_revision: None, + } + } + + fn channel(counter: Arc) -> Channel { + Channel::new(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } + + fn recording_channel(messages: Arc>>) -> Channel { + Channel::new(move |body| { + let InvokeResponseBody::Json(json) = body else { + panic!("preview envelope must use a JSON channel body"); + }; + messages + .lock() + .push(serde_json::from_str(&json).expect("preview envelope is valid JSON")); + Ok(()) + }) + } + + fn subscribe(broker: &PreviewBroker, label: &str) -> Arc { + let counter = Arc::new(AtomicUsize::new(0)); + broker + .subscribe(label, channel(counter.clone())) + .expect("subscribe succeeds"); + counter + } + + #[test] + fn rejects_publish_from_non_owner() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + subscribe(&broker, "intruder"); + let session_id = session_id(); + + broker + .publish("owner", request(&session_id, 1)) + .expect("owner claims session"); + + assert!(broker + .publish("intruder", request(&session_id, 2)) + .unwrap_err() + .contains("another window")); + } + + #[test] + fn publish_injects_source_label_and_defaults_to_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "keyPosition", + "mode": "default", + "targets": [0], + "patch": { "width": 80 } + })) + .expect("frontend request shape is accepted"); + + broker.publish("owner", request).expect("publish succeeds"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].source_label, "owner"); + assert_eq!(messages[0].kind, PreviewKind::Patch); + } + + #[test] + fn forwards_every_position_preview_domain() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let domains = [ + PreviewDomain::KeyPosition, + PreviewDomain::StatPosition, + PreviewDomain::GraphPosition, + PreviewDomain::KnobPosition, + ]; + + for (index, domain) in domains.iter().copied().enumerate() { + broker + .publish( + "owner", + request_for_domain(&session_id, index as u64 + 1, domain), + ) + .expect("supported domain publishes"); + } + + let forwarded = messages + .lock() + .iter() + .map(|message| message.domain) + .collect::>(); + assert_eq!(forwarded, domains); + } + + #[test] + fn rejects_non_monotonic_sequence() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 2)) + .expect("first publish succeeds"); + + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("monotonically")); + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("monotonically")); + } + + #[test] + fn rejects_oversized_payload() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut oversized = request(&session_id, 1); + oversized.patch.insert( + "displayText".to_string(), + Value::String("x".repeat(MAX_PREVIEW_BYTES)), + ); + + assert!(broker + .publish("owner", oversized) + .unwrap_err() + .contains("byte limit")); + } + + #[test] + fn rejects_patch_field_outside_allowlist() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut invalid = request(&session_id, 1); + invalid + .patch + .insert("groupId".to_string(), Value::from("g1")); + + assert!(broker + .publish("owner", invalid) + .unwrap_err() + .contains("not allowed")); + } + + #[test] + fn resubscribe_cancels_previous_generation_sessions() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + subscribe(&broker, "owner"); + + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); + } + + #[test] + fn successful_commit_ends_preview_session() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + + assert!(broker + .finish_committed_session("owner", &session_id, Some(7)) + .expect("commit cleanup succeeds")); + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); + } + + #[test] + fn commit_before_first_publish_rejects_late_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + + assert!(broker + .finish_committed_session("owner", &session_id, None) + .expect("commit cleanup succeeds")); + + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("already ended")); + } + + #[test] + fn cancel_before_first_publish_rejects_late_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + + broker + .cancel("owner", &session_id, None) + .expect("early cancel succeeds"); + + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("already ended")); + } + + #[test] + fn destroyed_window_cancels_owned_sessions() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + + broker.remove_label("owner"); + + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); + } + + #[test] + fn history_gate_rejects_busy_preview_admission() { + let broker = PreviewBroker::default(); + let gate = Arc::clone(&broker.history_gate); + let operation_id = Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + + let subscribe_error = broker + .subscribe("owner", channel(Arc::new(AtomicUsize::new(0)))) + .unwrap_err(); + assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); + let publish_error = broker + .publish("owner", request(&session_id(), 1)) + .unwrap_err(); + assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); + drop(barrier); + } + + #[test] + fn preview_revalidates_admission_generation_after_broker_wait() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let gate = Arc::clone(&broker.history_gate); + + let subscribe_admission = gate.try_admit().unwrap(); + let first_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); + drop(first_barrier); + let subscribe_error = broker + .subscribe_after_admission( + "observer", + channel(Arc::new(AtomicUsize::new(0))), + subscribe_admission, + ) + .unwrap_err(); + assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); + + let publish_admission = gate.try_admit().unwrap(); + let second_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); + drop(second_barrier); + let session_id = session_id(); + let publish_error = broker + .publish_after_admission( + "owner", + envelope("owner", request(&session_id, 1)), + publish_admission, + ) + .unwrap_err(); + assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); + } + + #[test] + fn cancel_all_tombstones_every_active_session() { + let broker = PreviewBroker::default(); + let owner_messages = subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + assert_eq!(broker.cancel_all(), 1); + assert_eq!(owner_messages.load(Ordering::SeqCst), 1); + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker.state.lock().sessions.is_empty()); + assert!(broker.state.lock().tombstones.contains(&session_id)); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); + } +} diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index c39b72de..a1f089cd 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -1,6 +1,6 @@ use std::time::Instant; use std::{ - collections::HashSet, + collections::{HashSet, VecDeque}, io::{BufRead, BufReader}, path::Path, process::{Child, ChildStdin, Command, Stdio}, @@ -14,8 +14,8 @@ use std::{ use anyhow::{anyhow, Context, Result}; use log::{error, warn}; -use parking_lot::{Mutex, RwLock}; -use serde::Serialize; +use parking_lot::{Condvar, Mutex, RwLock}; +use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::{ menu::{Menu, MenuItem}, @@ -23,11 +23,20 @@ use tauri::{ AppHandle, Emitter, Manager, Monitor, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; -use tauri_runtime_wry::wry::dpi::{LogicalPosition, LogicalSize}; +use tauri_runtime_wry::wry::dpi::{LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize}; +use tokio::sync::oneshot; -use super::store::AppStore; +use super::{ + history::{ + HistoryAdmissionGate, HistoryAdmissionLease, HistoryBarrierLease, HistoryBarrierWaiter, + HISTORY_IN_PROGRESS, + }, + plugin::{PluginAuthorityLease, PluginRpcRouter, PluginRuntimeAuthority}, + store::AppStore, +}; #[cfg(debug_assertions)] use crate::audio::KeySoundDispatchTrace; +use crate::errors::EditorCommitError; use crate::{ audio::{ KeySoundEngine, KeySoundOutputBackend, KeySoundOutputDevices, KeySoundOutputState, @@ -36,32 +45,82 @@ use crate::{ keyboard::KeyboardManager, models::{ overlay_resize_anchor_from_str, AppStoreData, BootstrapOverlayState, BootstrapPayload, - DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, - KeySoundOutputBackendPersist, OverlayBounds, OverlayResizeAnchor, SettingsDiff, - SettingsState, + DefaultsPayload, HistoryStatus, KeyCounterSettings, KeyCounters, KeyMappings, + KeySoundOutputBackendPersist, OverlayBounds, OverlayResizeAnchor, PanelBounds, + SettingsDiff, SettingsState, TabCssOverrides, }, services::{css_watcher::CssWatcher, obs_bridge::ObsBridgeService, settings::SettingsService}, state::local_asset_path::path_identity_key, }; const OVERLAY_LABEL: &str = "overlay"; +pub(crate) const PANEL_LABEL: &str = "panel"; +const PANEL_ENTRYPOINT: &str = "panel/index.html"; +const RAW_INPUT_WINDOW_LABELS: [&str; 3] = ["main", OVERLAY_LABEL, PANEL_LABEL]; +const FRONTEND_LIFECYCLE_WINDOW_LABELS: [&str; 3] = ["main", OVERLAY_LABEL, PANEL_LABEL]; const TRAY_ICON_ID: &str = "background-tray"; const TRAY_MENU_SETTINGS_ID: &str = "tray-settings"; const TRAY_MENU_QUIT_ID: &str = "tray-quit"; const DEFAULT_OVERLAY_WIDTH: f64 = 860.0; const DEFAULT_OVERLAY_HEIGHT: f64 = 320.0; +const PANEL_WIDTH: f64 = 240.0; +const PANEL_INITIAL_HEIGHT: f64 = 530.0; +const PANEL_MIN_HEIGHT: f64 = 360.0; +const PANEL_MAX_HEIGHT_RATIO: f64 = 0.9; +const PANEL_FALLBACK_MAX_HEIGHT: f64 = 10_000.0; +const PANEL_BOUNDS_DEBOUNCE_MS: u64 = 400; +const PANEL_CLOSE_ACK_TIMEOUT: Duration = Duration::from_millis(1_500); +const MAX_SELECTION_ELEMENTS: usize = 4_096; +const MAX_SELECTION_GROUP_IDS: usize = 4_096; +const MAX_SELECTION_ELEMENT_TYPE_BYTES: usize = 64; +const MAX_SELECTION_FULL_ID_BYTES: usize = 512; +const MAX_SELECTION_GROUP_ID_BYTES: usize = 512; +const MAX_SELECTION_MODE_BYTES: usize = 128; const OVERLAY_MARGIN: f64 = 40.0; const OVERLAY_BOUNDS_DEBOUNCE_MS: u64 = 400; const OVERLAY_CREATION_LOCK_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_FLUSH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(5); const SHUTDOWN_WATCHDOG_EXIT_CODE: i32 = 1; +const HISTORY_FRONTEND_FLUSH_BUSY: &str = "HISTORY_FRONTEND_FLUSH_BUSY"; +const HISTORY_FRONTEND_FLUSH_CANCELED: &str = "HISTORY_FRONTEND_FLUSH_CANCELED"; +const HISTORY_FRONTEND_FLUSH_EMIT_FAILED: &str = "HISTORY_FRONTEND_FLUSH_EMIT_FAILED"; +const HISTORY_FRONTEND_FLUSH_INTERRUPTED: &str = "HISTORY_FRONTEND_FLUSH_INTERRUPTED"; +const HISTORY_FRONTEND_FLUSH_TIMEOUT: &str = "HISTORY_FRONTEND_FLUSH_TIMEOUT"; struct ShutdownWatchdogState { armed: bool, stage: &'static str, } +#[derive(Debug, Clone, Copy)] +struct PanelBoundsSample { + position: PhysicalPosition, + position_scale_factor: f64, + size: PhysicalSize, + size_scale_factor: f64, + current_scale_factor: f64, +} + +#[derive(Debug, Clone, Copy)] +enum PanelBoundsChange { + Moved(PhysicalPosition), + Resized(PhysicalSize), + ScaleFactorChanged { + position: Option>, + size: PhysicalSize, + scale_factor: f64, + }, +} + +#[derive(Debug)] +struct PanelBoundsSettleState { + latest: Option, + generation: u64, + worker_running: bool, + active: bool, +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum FrontendLifecycleAction { @@ -69,19 +128,439 @@ pub(crate) enum FrontendLifecycleAction { Restart, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum FrontendFlushAction { + Quit, + Restart, + History, +} + +impl From for FrontendFlushAction { + fn from(action: FrontendLifecycleAction) -> Self { + match action { + FrontendLifecycleAction::Quit => Self::Quit, + FrontendLifecycleAction::Restart => Self::Restart, + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct EditorFlushRequest { handshake_id: String, - action: FrontendLifecycleAction, + action: FrontendFlushAction, +} + +enum EditorFlushCompletion { + Lifecycle(FrontendLifecycleAction), + History { + operation_id: String, + sender: Option>>, + phase: FrontendHistoryFlushPhase, + barrier: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrontendHistoryFlushPhase { + Collecting, + Closing, + Running, +} + +impl EditorFlushCompletion { + fn is_lifecycle(&self) -> bool { + matches!(self, Self::Lifecycle(_)) + } + + fn is_history(&self) -> bool { + matches!(self, Self::History { .. }) + } + + fn history_phase(&self) -> Option { + match self { + Self::History { phase, .. } => Some(*phase), + Self::Lifecycle(_) => None, + } + } +} + +pub(crate) struct FrontendHistoryFlushReady { + barrier: Option, + complete: Option>, +} + +impl FrontendHistoryFlushReady { + pub(crate) fn take_barrier(&mut self) -> HistoryBarrierLease { + self.barrier + .take() + .expect("history flush barrier can only be taken once") + } +} + +impl Drop for FrontendHistoryFlushReady { + fn drop(&mut self) { + drop(self.barrier.take()); + if let Some(complete) = self.complete.take() { + complete(); + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FrontendHistoryFlushReleased { + handshake_id: String, } struct EditorFlushHandshake { id: String, - action: FrontendLifecycleAction, + completion: EditorFlushCompletion, + target_windows: HashSet, pending_windows: HashSet, } +fn take_editor_flush_handshake( + slot: &mut Option, + handshake_id: &str, +) -> Option { + if slot + .as_ref() + .is_some_and(|active| active.id == handshake_id) + { + slot.take() + } else { + None + } +} + +fn take_cancelable_editor_flush_handshake( + slot: &mut Option, + handshake_id: &str, +) -> Option { + if slot.as_ref().is_some_and(|active| { + active.id == handshake_id + && active.completion.history_phase() != Some(FrontendHistoryFlushPhase::Running) + }) { + slot.take() + } else { + None + } +} + +enum EditorFlushAcknowledge { + LifecycleReady(EditorFlushHandshake), + HistoryClosing { + handshake_id: String, + waiter: HistoryBarrierWaiter, + }, + HistoryCloseFailed { + handshake: EditorFlushHandshake, + error: String, + }, +} + +fn acknowledge_editor_flush_handshake( + slot: &mut Option, + handshake_id: &str, + window_label: &str, + history_gate: &Arc, +) -> Option { + { + let active = slot.as_mut()?; + if active.id != handshake_id || !active.pending_windows.remove(window_label) { + return None; + } + if !active.pending_windows.is_empty() { + return None; + } + if active.completion.is_lifecycle() { + return slot.take().map(EditorFlushAcknowledge::LifecycleReady); + } + } + begin_history_gate_close(slot, handshake_id, history_gate) +} + +fn begin_history_gate_close( + slot: &mut Option, + handshake_id: &str, + history_gate: &Arc, +) -> Option { + let operation_id = match slot.as_ref() { + Some(EditorFlushHandshake { + id, + completion: + EditorFlushCompletion::History { + operation_id, + phase: FrontendHistoryFlushPhase::Collecting, + .. + }, + .. + }) if id == handshake_id => operation_id.clone(), + _ => return None, + }; + match history_gate.begin_close(&operation_id) { + Ok(next_barrier) => { + let waiter = next_barrier.waiter(); + let active = slot + .as_mut() + .expect("history handshake disappeared while closing gate"); + let EditorFlushCompletion::History { phase, barrier, .. } = &mut active.completion + else { + unreachable!("history handshake changed while closing gate"); + }; + *phase = FrontendHistoryFlushPhase::Closing; + *barrier = Some(next_barrier); + Some(EditorFlushAcknowledge::HistoryClosing { + handshake_id: handshake_id.to_string(), + waiter, + }) + } + Err(error) => slot + .take() + .map(|handshake| EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }), + } +} + +enum LifecycleHandshakeInstall { + Installed, + InterruptedHistory(Box), + LifecycleAlreadyActive, + DeferredUntilHistoryComplete, +} + +fn install_lifecycle_handshake( + slot: &mut Option, + next: EditorFlushHandshake, +) -> LifecycleHandshakeInstall { + if slot + .as_ref() + .is_some_and(|active| active.completion.is_lifecycle()) + { + return LifecycleHandshakeInstall::LifecycleAlreadyActive; + } + if slot.as_ref().is_some_and(|active| { + active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Running) + }) { + return LifecycleHandshakeInstall::DeferredUntilHistoryComplete; + } + + match slot.replace(next) { + Some(interrupted) => LifecycleHandshakeInstall::InterruptedHistory(Box::new(interrupted)), + None => LifecycleHandshakeInstall::Installed, + } +} + +fn install_history_handshake( + slot: &mut Option, + next: EditorFlushHandshake, +) -> bool { + if slot.is_some() { + return false; + } + *slot = Some(next); + true +} + +fn frontend_history_mutation_blocked( + slot: &Option, + window_label: &str, +) -> bool { + slot.as_ref().is_some_and(|active| { + active.completion.is_history() && !active.pending_windows.contains(window_label) + }) +} + +fn emit_frontend_history_flush_released( + app_handle: &AppHandle, + handshake_id: &str, + target_windows: &HashSet, +) { + let payload = FrontendHistoryFlushReleased { + handshake_id: handshake_id.to_string(), + }; + for label in target_windows { + if let Err(error) = app_handle.emit_to(label, "app:history-flush-released", &payload) { + log::warn!("failed to release history flush lock for {label}: {error}"); + } + } +} + +fn frontend_lifecycle_restore_labels(target_windows: &HashSet) -> Vec<&'static str> { + FRONTEND_LIFECYCLE_WINDOW_LABELS + .into_iter() + .filter(|label| target_windows.contains(*label)) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectionSessionElement { + pub element_type: String, + #[serde(default)] + pub index: Option, + #[serde(default)] + pub full_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectionSessionSnapshot { + #[serde(default)] + pub selected_elements: Vec, + #[serde(default)] + pub selected_group_ids: Vec, + #[serde(default)] + pub mode: String, + #[serde(default)] + pub selection_revision: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PanelVisibilityPayload { + visible: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum PanelVisibilityReason { + Closed, + Destroyed, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PanelCloseRequestedPayload { + request_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PanelViewState { + pub mode: PanelViewMode, + pub active_tab: PanelLayerTab, + pub property_active_tab: PanelPropertyTab, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PanelViewMode { + Layer, + Property, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PanelLayerTab { + Layer, + Grid, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PanelPropertyTab { + Style, + Note, + Counter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PanelViewTarget { + Main, + Panel, +} + +impl PanelViewTarget { + fn matches_window(self, label: &str) -> bool { + matches!( + (self, label), + (Self::Main, "main") | (Self::Panel, PANEL_LABEL) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TargetedPanelViewState { + target: PanelViewTarget, + view_state: PanelViewState, +} + +fn take_targeted_panel_view_state( + slot: &mut Option, + window_label: &str, +) -> Option { + if slot + .as_ref() + .is_some_and(|pending| pending.target.matches_window(window_label)) + { + slot.take().map(|pending| pending.view_state) + } else { + None + } +} + +fn clear_targeted_panel_view_state( + slot: &mut Option, + target: PanelViewTarget, +) { + if slot + .as_ref() + .is_some_and(|pending| pending.target == target) + { + *slot = None; + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +enum PanelCloseRequestState { + #[default] + Idle, + Pending(String), + Closing, +} + +trait PanelVisibilityEventEmitter { + fn emit_panel_visibility(&self, payload: PanelVisibilityPayload) -> Result<()>; +} + +impl PanelVisibilityEventEmitter for AppHandle { + fn emit_panel_visibility(&self, payload: PanelVisibilityPayload) -> Result<()> { + self.emit("panel:visibility", payload)?; + Ok(()) + } +} + +#[derive(Debug)] +struct QueuedCounterIncrement { + mode: String, + key: String, +} + +#[derive(Debug, Default)] +struct CounterHistoryBarrierState { + queueing: bool, + active_increments: usize, + queued: VecDeque, +} + +#[derive(Debug, Default)] +struct RuntimePublicationState { + mappings_generation: u64, + mode_generation: u64, + counters_generation: u64, +} + +#[derive(Debug)] +pub(crate) struct AdmittedCounterMutation { + pub(crate) counters: KeyCounters, + pub(crate) history_status: Option, + _admission: HistoryAdmissionLease, +} + /// 카운터 write lock 내부 전용 이벤트 송신 경계 /// 동기 Rust listener에서 AppState 카운터 API 재진입 금지 pub(crate) trait KeyCounterEventEmitter { @@ -116,6 +595,159 @@ fn bootstrap_active_keys(keyboard: &KeyboardManager) -> Vec { keyboard.pressed_keys() } +fn collect_frontend_lifecycle_targets( + mut resolve: impl FnMut(&str) -> Option, +) -> Vec<(String, T)> { + FRONTEND_LIFECYCLE_WINDOW_LABELS + .into_iter() + .filter_map(|label| resolve(label).map(|target| (label.to_string(), target))) + .collect() +} + +fn validate_selection_session(snapshot: &SelectionSessionSnapshot) -> Result<(), String> { + if snapshot.selected_elements.len() > MAX_SELECTION_ELEMENTS { + return Err(format!( + "selectedElements exceeds the {MAX_SELECTION_ELEMENTS} item limit" + )); + } + if snapshot.selected_group_ids.len() > MAX_SELECTION_GROUP_IDS { + return Err(format!( + "selectedGroupIds exceeds the {MAX_SELECTION_GROUP_IDS} item limit" + )); + } + if snapshot.mode.len() > MAX_SELECTION_MODE_BYTES { + return Err(format!( + "mode exceeds the {MAX_SELECTION_MODE_BYTES} byte limit" + )); + } + for (index, element) in snapshot.selected_elements.iter().enumerate() { + if element.element_type.len() > MAX_SELECTION_ELEMENT_TYPE_BYTES { + return Err(format!( + "selectedElements[{index}].elementType exceeds the {MAX_SELECTION_ELEMENT_TYPE_BYTES} byte limit" + )); + } + if element + .full_id + .as_ref() + .is_some_and(|full_id| full_id.len() > MAX_SELECTION_FULL_ID_BYTES) + { + return Err(format!( + "selectedElements[{index}].fullId exceeds the {MAX_SELECTION_FULL_ID_BYTES} byte limit" + )); + } + } + for (index, group_id) in snapshot.selected_group_ids.iter().enumerate() { + if group_id.len() > MAX_SELECTION_GROUP_ID_BYTES { + return Err(format!( + "selectedGroupIds[{index}] exceeds the {MAX_SELECTION_GROUP_ID_BYTES} byte limit" + )); + } + } + Ok(()) +} + +fn publish_selection_snapshot( + session: &Mutex, + mut snapshot: SelectionSessionSnapshot, +) -> Result { + validate_selection_session(&snapshot)?; + let mut current = session.lock(); + snapshot.selection_revision = current + .selection_revision + .checked_add(1) + .ok_or_else(|| "selection revision overflow".to_string())?; + *current = snapshot.clone(); + Ok(snapshot) +} + +fn publish_panel_visibility_transition( + visible_state: &AtomicBool, + emitter: &dyn PanelVisibilityEventEmitter, + visible: bool, + reason: Option, +) -> Result<()> { + let previous = visible_state.swap(visible, Ordering::SeqCst); + if previous == visible { + return Ok(()); + } + let payload = PanelVisibilityPayload { + visible, + reason: if visible { None } else { reason }, + }; + if let Err(error) = emitter.emit_panel_visibility(payload) { + let _ = + visible_state.compare_exchange(visible, previous, Ordering::SeqCst, Ordering::SeqCst); + return Err(error); + } + Ok(()) +} + +fn publish_panel_hidden_transition( + visible_state: &AtomicBool, + reason_state: &Mutex>, + emitter: &dyn PanelVisibilityEventEmitter, + fallback_reason: PanelVisibilityReason, +) -> Result<()> { + let reason = reason_state.lock().take().unwrap_or(fallback_reason); + if let Err(error) = + publish_panel_visibility_transition(visible_state, emitter, false, Some(reason)) + { + let mut pending = reason_state.lock(); + if pending.is_none() { + *pending = Some(reason); + } + return Err(error); + } + Ok(()) +} + +fn begin_panel_close_request(state: &Mutex, request_id: &str) -> bool { + let mut current = state.lock(); + if *current != PanelCloseRequestState::Idle { + return false; + } + *current = PanelCloseRequestState::Pending(request_id.to_string()); + true +} + +fn acknowledge_panel_close_request( + state: &Mutex, + request_id: &str, +) -> bool { + let mut current = state.lock(); + if !matches!(&*current, PanelCloseRequestState::Pending(active) if active == request_id) { + return false; + } + *current = PanelCloseRequestState::Idle; + true +} + +fn claim_panel_close_timeout(state: &Mutex, request_id: &str) -> bool { + let mut current = state.lock(); + if !matches!(&*current, PanelCloseRequestState::Pending(active) if active == request_id) { + return false; + } + *current = PanelCloseRequestState::Closing; + true +} + +fn finish_panel_close(state: &Mutex) { + *state.lock() = PanelCloseRequestState::Idle; +} + +fn run_panel_close_timeout( + state: &Mutex, + request_id: &str, + fallback: impl FnOnce() -> Result<()>, +) -> Result { + if !claim_panel_close_timeout(state, request_id) { + return Ok(false); + } + let result = fallback(); + finish_panel_close(state); + result.map(|()| true) +} + pub struct AppState { pub store: Arc, pub settings: SettingsService, @@ -128,8 +760,20 @@ pub struct AppState { /// 메인 스레드 이벤트 콜백에서 획득 금지 — setup 훅은 이벤트 루프 가동 전이라 예외 overlay_creation_lock: Mutex<()>, overlay_bounds_generation: Arc, + selection_session: Mutex, + plugin_authority: PluginRuntimeAuthority, + plugin_rpc_router: PluginRpcRouter, + panel_bounds_generation: Arc, + panel_visible: AtomicBool, + panel_creation_lock: Mutex<()>, + panel_close_request: Mutex, + panel_destroy_reason: Mutex>, + panel_view_state: Mutex>, keyboard_task: RwLock>, key_counters: Arc>, + counter_history_barrier: Mutex, + counter_history_ready: Condvar, + runtime_publication: Mutex, key_counter_enabled: Arc, /// Raw input stream subscriber count - emit only when > 0 raw_input_subscribers: Arc, @@ -145,8 +789,10 @@ pub struct AppState { /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용) obs_previous_overlay_visible: Arc>>, shutdown_started: AtomicBool, + process_exit_authorized: AtomicBool, shutdown_watchdog: Arc>, editor_flush_handshake: Arc>>, + deferred_frontend_lifecycle: Mutex>, } impl AppState { @@ -173,6 +819,10 @@ impl AppState { let key_sound = Arc::new(KeySoundEngine::with_output_backend(initial_backend)); let obs_bridge = Arc::new(ObsBridgeService::new(env!("CARGO_PKG_VERSION"))); let authorized_css_paths = collect_authorized_css_paths(&snapshot); + let selection_session = SelectionSessionSnapshot { + mode: snapshot.selected_key_type.clone(), + ..SelectionSessionSnapshot::default() + }; Ok(Self { store, @@ -183,8 +833,20 @@ impl AppState { overlay_initializing: Arc::new(AtomicBool::new(false)), overlay_creation_lock: Mutex::new(()), overlay_bounds_generation: Arc::new(AtomicU64::new(0)), + selection_session: Mutex::new(selection_session), + plugin_authority: PluginRuntimeAuthority::default(), + plugin_rpc_router: PluginRpcRouter::default(), + panel_bounds_generation: Arc::new(AtomicU64::new(0)), + panel_visible: AtomicBool::new(false), + panel_creation_lock: Mutex::new(()), + panel_close_request: Mutex::new(PanelCloseRequestState::Idle), + panel_destroy_reason: Mutex::new(None), + panel_view_state: Mutex::new(None), keyboard_task: RwLock::new(None), key_counters, + counter_history_barrier: Mutex::new(CounterHistoryBarrierState::default()), + counter_history_ready: Condvar::new(), + runtime_publication: Mutex::new(RuntimePublicationState::default()), key_counter_enabled, raw_input_subscribers: Arc::new(std::sync::atomic::AtomicU32::new(0)), key_sound, @@ -194,11 +856,13 @@ impl AppState { obs_bridge, obs_previous_overlay_visible: Arc::new(RwLock::new(None)), shutdown_started: AtomicBool::new(false), + process_exit_authorized: AtomicBool::new(false), shutdown_watchdog: Arc::new(Mutex::new(ShutdownWatchdogState { armed: false, stage: "shutdown initialization", })), editor_flush_handshake: Arc::new(Mutex::new(None)), + deferred_frontend_lifecycle: Mutex::new(None), }) } @@ -216,6 +880,9 @@ impl AppState { if let Some(overlay) = app.get_webview_window("overlay") { overlay.open_devtools(); } + if let Some(panel) = app.get_webview_window(PANEL_LABEL) { + panel.open_devtools(); + } } self.start_keyboard_hook(app.clone())?; // CSS 핫리로딩 워처 초기화 @@ -759,6 +1426,14 @@ impl AppState { self.shutdown_watchdog.lock().stage = stage; } + pub fn is_process_exit_authorized(&self) -> bool { + self.process_exit_authorized.load(Ordering::SeqCst) + } + + fn authorize_process_exit(&self) { + self.process_exit_authorized.store(true, Ordering::SeqCst); + } + pub fn request_frontend_shutdown(&self, app_handle: AppHandle) { self.request_frontend_lifecycle(app_handle, FrontendLifecycleAction::Quit); } @@ -773,67 +1448,288 @@ impl AppState { handshake_id: &str, window_label: &str, ) { - let action = { + let prepared = { let mut handshake = self.editor_flush_handshake.lock(); - let Some(active) = handshake.as_mut() else { - return; - }; - if active.id != handshake_id || !active.pending_windows.remove(window_label) { - return; - } - if active.pending_windows.is_empty() { - handshake.take().map(|completed| completed.action) - } else { - None - } + acknowledge_editor_flush_handshake( + &mut handshake, + handshake_id, + window_label, + &self.store.history_gate(), + ) }; - if let Some(action) = action { - execute_frontend_lifecycle(app_handle, action, self.overlay_force_close.clone()); + match prepared { + Some(EditorFlushAcknowledge::LifecycleReady(completed)) => { + self.complete_editor_flush_handshake(app_handle, completed); + } + Some(EditorFlushAcknowledge::HistoryClosing { + handshake_id, + waiter, + }) => { + let drain_result = waiter.wait_for_drain(); + self.finish_frontend_history_gate_close(app_handle, &handshake_id, drain_result); + } + Some(EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }) => { + log::warn!("failed to close history admission gate: {error}"); + self.fail_editor_flush_handshake( + &app_handle, + handshake, + HISTORY_FRONTEND_FLUSH_BUSY, + ); + } + None => {} } } - pub fn cancel_frontend_lifecycle(&self, handshake_id: &str) { - let mut handshake = self.editor_flush_handshake.lock(); - if handshake - .as_ref() - .is_some_and(|active| active.id == handshake_id) - { - handshake.take(); + pub(crate) fn admit_frontend_history_mutation( + &self, + window_label: &str, + ) -> Result { + let handshake = self.editor_flush_handshake.lock(); + if frontend_history_mutation_blocked(&handshake, window_label) { + return Err(HISTORY_IN_PROGRESS.to_string()); } + self.store.history_gate().admit_mutation() } - fn request_frontend_lifecycle(&self, app_handle: AppHandle, action: FrontendLifecycleAction) { - let targets = ["main", OVERLAY_LABEL] - .into_iter() - .filter_map(|label| { - app_handle - .get_webview_window(label) - .map(|window| (label.to_string(), window)) - }) - .collect::>(); + pub fn cancel_frontend_lifecycle(&self, app_handle: AppHandle, handshake_id: &str) { + let canceled = { + let mut handshake = self.editor_flush_handshake.lock(); + take_cancelable_editor_flush_handshake(&mut handshake, handshake_id) + }; + if let Some(canceled) = canceled { + self.fail_editor_flush_handshake( + &app_handle, + canceled, + HISTORY_FRONTEND_FLUSH_CANCELED, + ); + } + } - if targets.is_empty() { - execute_frontend_lifecycle(app_handle, action, self.overlay_force_close.clone()); - return; + fn complete_editor_flush_handshake( + &self, + app_handle: AppHandle, + completed: EditorFlushHandshake, + ) { + let EditorFlushHandshake { completion, .. } = completed; + match completion { + EditorFlushCompletion::Lifecycle(action) => { + execute_frontend_lifecycle(app_handle, action, self.overlay_force_close.clone()); + } + EditorFlushCompletion::History { .. } => { + log::error!("history handshake completed through lifecycle path"); + } } + } - let handshake_id = uuid::Uuid::new_v4().to_string(); - { - let mut handshake = self.editor_flush_handshake.lock(); - if handshake.is_some() { - return; + fn finish_frontend_history_gate_close( + &self, + app_handle: AppHandle, + handshake_id: &str, + drain_result: std::result::Result<(), String>, + ) { + if let Err(error) = drain_result { + let failed = { + let mut active = self.editor_flush_handshake.lock(); + take_cancelable_editor_flush_handshake(&mut active, handshake_id) + }; + if let Some(failed) = failed { + log::warn!("history admission drain was interrupted: {error}"); + self.fail_editor_flush_handshake( + &app_handle, + failed, + HISTORY_FRONTEND_FLUSH_INTERRUPTED, + ); } - *handshake = Some(EditorFlushHandshake { - id: handshake_id.clone(), - action, - pending_windows: targets.iter().map(|(label, _)| label.clone()).collect(), - }); + return; + } + + let prepared = { + let mut active = self.editor_flush_handshake.lock(); + let Some(handshake) = active.as_mut().filter(|item| item.id == handshake_id) else { + return; + }; + let EditorFlushCompletion::History { + sender, + phase, + barrier, + .. + } = &mut handshake.completion + else { + return; + }; + if *phase != FrontendHistoryFlushPhase::Closing { + return; + } + let Some(sender) = sender.take() else { + return; + }; + let Some(barrier) = barrier.take() else { + return; + }; + *phase = FrontendHistoryFlushPhase::Running; + Some((sender, barrier)) + }; + + let Some((sender, barrier)) = prepared else { + return; + }; + let completion_app = app_handle.clone(); + let completion_id = handshake_id.to_string(); + let ready = FrontendHistoryFlushReady { + barrier: Some(barrier), + complete: Some(Box::new(move || { + let state = completion_app.state::(); + state.complete_frontend_history_operation(&completion_app, &completion_id); + })), + }; + let _ = sender.send(Ok(ready)); + } + + fn complete_frontend_history_operation(&self, app_handle: &AppHandle, handshake_id: &str) { + let (completed, deferred_action) = { + let mut active = self.editor_flush_handshake.lock(); + let is_running = active.as_ref().is_some_and(|handshake| { + handshake.id == handshake_id + && handshake.completion.history_phase() + == Some(FrontendHistoryFlushPhase::Running) + }); + if !is_running { + return; + } + let completed = active + .take() + .expect("running history handshake disappeared"); + let deferred_action = self.deferred_frontend_lifecycle.lock().take(); + (completed, deferred_action) + }; + emit_frontend_history_flush_released(app_handle, &completed.id, &completed.target_windows); + if let Some(action) = deferred_action { + self.request_frontend_lifecycle(app_handle.clone(), action); + } + } + + fn fail_editor_flush_handshake( + &self, + app_handle: &AppHandle, + failed: EditorFlushHandshake, + history_error: &'static str, + ) { + let EditorFlushHandshake { + id, + completion, + target_windows, + .. + } = failed; + match completion { + EditorFlushCompletion::Lifecycle(_) => { + #[cfg(target_os = "macos")] + if let Err(error) = super::macos_termination::cancel_pending_termination(app_handle) + { + log::warn!("failed to cancel pending macOS termination: {error}"); + } + self.restore_frontend_lifecycle_windows(app_handle, &target_windows); + } + EditorFlushCompletion::History { + sender, barrier, .. + } => { + drop(barrier); + emit_frontend_history_flush_released(app_handle, &id, &target_windows); + if let Some(sender) = sender { + let _ = sender.send(Err(history_error.to_string())); + } + } + } + } + + fn restore_frontend_lifecycle_windows( + &self, + app_handle: &AppHandle, + target_windows: &HashSet, + ) { + for label in frontend_lifecycle_restore_labels(target_windows) { + match label { + "main" => { + if let Err(error) = self.show_main_window(app_handle) { + log::warn!( + "failed to restore main window after canceled lifecycle: {error}" + ); + } + } + OVERLAY_LABEL if *self.overlay_visible.read() => { + if let Err(error) = self.set_overlay_visibility(app_handle, true) { + log::warn!("failed to restore overlay after canceled lifecycle: {error}"); + } + } + PANEL_LABEL => { + if let Some(panel) = app_handle.get_webview_window(PANEL_LABEL) { + if let Err(error) = panel.show() { + log::warn!("failed to restore panel after canceled lifecycle: {error}"); + } + } + } + _ => {} + } + } + } + + fn request_frontend_lifecycle(&self, app_handle: AppHandle, action: FrontendLifecycleAction) { + if self.overlay_force_close.load(Ordering::SeqCst) + || self.shutdown_started.load(Ordering::SeqCst) + { + return; + } + + let targets = + collect_frontend_lifecycle_targets(|label| app_handle.get_webview_window(label)); + let handshake_id = uuid::Uuid::new_v4().to_string(); + let target_windows = targets + .iter() + .map(|(label, _)| label.clone()) + .collect::>(); + let next_handshake = EditorFlushHandshake { + id: handshake_id.clone(), + completion: EditorFlushCompletion::Lifecycle(action), + pending_windows: target_windows.clone(), + target_windows, + }; + let interrupted_history = { + let mut active = self.editor_flush_handshake.lock(); + match install_lifecycle_handshake(&mut active, next_handshake) { + LifecycleHandshakeInstall::Installed => None, + LifecycleHandshakeInstall::InterruptedHistory(interrupted) => Some(*interrupted), + LifecycleHandshakeInstall::LifecycleAlreadyActive => return, + LifecycleHandshakeInstall::DeferredUntilHistoryComplete => { + let mut deferred = self.deferred_frontend_lifecycle.lock(); + if deferred.is_none() { + *deferred = Some(action); + } + return; + } + } + }; + if let Some(interrupted) = interrupted_history { + self.fail_editor_flush_handshake( + &app_handle, + interrupted, + HISTORY_FRONTEND_FLUSH_INTERRUPTED, + ); + } + + if targets.is_empty() { + let completed = { + let mut active = self.editor_flush_handshake.lock(); + take_editor_flush_handshake(&mut active, &handshake_id) + }; + if let Some(completed) = completed { + self.complete_editor_flush_handshake(app_handle, completed); + } + return; } let request = EditorFlushRequest { handshake_id: handshake_id.clone(), - action, + action: action.into(), }; let mut failed_windows = Vec::new(); for (label, window) in &targets { @@ -844,57 +1740,153 @@ impl AppState { } if !failed_windows.is_empty() { - let mut handshake = self.editor_flush_handshake.lock(); - let Some(active) = handshake.as_ref() else { - return; + let canceled = { + let mut handshake = self.editor_flush_handshake.lock(); + take_editor_flush_handshake(&mut handshake, &handshake_id) }; - if active.id != handshake_id { + let Some(canceled) = canceled else { return; - } - handshake.take(); - drop(handshake); + }; log::warn!( "editor flush request failed for {:?}; lifecycle action canceled", failed_windows ); - if let Err(error) = self.show_main_window(&app_handle) { - log::warn!("failed to restore main window after canceled lifecycle: {error}"); + self.fail_editor_flush_handshake( + &app_handle, + canceled, + HISTORY_FRONTEND_FLUSH_EMIT_FAILED, + ); + return; + } + + self.schedule_editor_flush_timeout(app_handle, handshake_id); + } + + pub(crate) fn request_frontend_history_flush( + &self, + app_handle: AppHandle, + operation_id: &str, + ) -> Result>, String> { + if self.overlay_force_close.load(Ordering::SeqCst) + || self.shutdown_started.load(Ordering::SeqCst) + { + return Err(HISTORY_FRONTEND_FLUSH_BUSY.to_string()); + } + + let targets = + collect_frontend_lifecycle_targets(|label| app_handle.get_webview_window(label)); + let target_windows = targets + .iter() + .map(|(label, _)| label.clone()) + .collect::>(); + let handshake_id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + let handshake = EditorFlushHandshake { + id: handshake_id.clone(), + completion: EditorFlushCompletion::History { + operation_id: operation_id.to_string(), + sender: Some(sender), + phase: FrontendHistoryFlushPhase::Collecting, + barrier: None, + }, + pending_windows: target_windows.clone(), + target_windows, + }; + { + let mut active = self.editor_flush_handshake.lock(); + if self.store.history_gate().is_closed() + || !install_history_handshake(&mut active, handshake) + { + return Err(HISTORY_FRONTEND_FLUSH_BUSY.to_string()); } - if *self.overlay_visible.read() { - if let Err(error) = self.set_overlay_visibility(&app_handle, true) { - log::warn!("failed to restore overlay after canceled lifecycle: {error}"); + } + + if targets.is_empty() { + let prepared = { + let mut active = self.editor_flush_handshake.lock(); + begin_history_gate_close(&mut active, &handshake_id, &self.store.history_gate()) + }; + match prepared { + Some(EditorFlushAcknowledge::HistoryClosing { + handshake_id, + waiter, + }) => { + let drain_result = waiter.wait_for_drain(); + self.finish_frontend_history_gate_close( + app_handle, + &handshake_id, + drain_result, + ); } + Some(EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }) => { + log::warn!("failed to close history admission gate: {error}"); + self.fail_editor_flush_handshake( + &app_handle, + handshake, + HISTORY_FRONTEND_FLUSH_BUSY, + ); + } + _ => {} } - return; + return Ok(receiver); + } + + let request = EditorFlushRequest { + handshake_id: handshake_id.clone(), + action: FrontendFlushAction::History, + }; + let mut failed_windows = Vec::new(); + for (label, window) in &targets { + if let Err(error) = window.emit("app:close-requested", &request) { + log::warn!("failed to request history flush from {label}: {error}"); + failed_windows.push(label.clone()); + } + } + + if !failed_windows.is_empty() { + let failed = { + let mut active = self.editor_flush_handshake.lock(); + take_cancelable_editor_flush_handshake(&mut active, &handshake_id) + }; + if let Some(failed) = failed { + log::warn!( + "history frontend flush request failed for {:?}", + failed_windows + ); + self.fail_editor_flush_handshake( + &app_handle, + failed, + HISTORY_FRONTEND_FLUSH_EMIT_FAILED, + ); + } + return Ok(receiver); } + self.schedule_editor_flush_timeout(app_handle, handshake_id); + Ok(receiver) + } + + fn schedule_editor_flush_timeout(&self, app_handle: AppHandle, handshake_id: String) { let handshake = self.editor_flush_handshake.clone(); - let overlay_force_close = self.overlay_force_close.clone(); - thread::spawn(move || { - thread::sleep(EDITOR_FLUSH_HANDSHAKE_TIMEOUT); - let main_is_visible = app_handle - .get_webview_window("main") - .and_then(|window| window.is_visible().ok()) - .unwrap_or(false); - let timed_out_action = { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(EDITOR_FLUSH_HANDSHAKE_TIMEOUT).await; + let timed_out = { let mut active = handshake.lock(); - if active - .as_ref() - .is_some_and(|pending| pending.id == handshake_id) - { - active.take().map(|pending| pending.action) - } else { - None - } + take_cancelable_editor_flush_handshake(&mut active, &handshake_id) }; - if let Some(action) = timed_out_action { - if main_is_visible { + if let Some(timed_out) = timed_out { + let state = app_handle.state::(); + if timed_out.completion.is_lifecycle() { log::warn!("editor flush handshake timed out; lifecycle action canceled"); } else { - log::warn!("editor flush handshake timed out while windows were hidden"); - execute_frontend_lifecycle(app_handle, action, overlay_force_close); + log::warn!("editor flush handshake timed out; history action canceled"); } + state.fail_editor_flush_handshake( + &app_handle, + timed_out, + HISTORY_FRONTEND_FLUSH_TIMEOUT, + ); } }); } @@ -1273,13 +2265,10 @@ impl AppState { "device": device_str, }); - // 메인 윈도우에 우선 emit - if let Some(main) = app_handle.get_webview_window("main") { - let _ = main.emit("input:raw", &raw_payload); - } - // 오버레이에도 emit (플러그인용) - if let Some(overlay) = app_handle.get_webview_window(OVERLAY_LABEL) { - let _ = overlay.emit("input:raw", &raw_payload); + for label in RAW_INPUT_WINDOW_LABELS { + if let Some(window) = app_handle.get_webview_window(label) { + let _ = window.emit("input:raw", &raw_payload); + } } } @@ -1478,6 +2467,318 @@ impl AppState { Ok(()) } + pub fn selection_session(&self) -> SelectionSessionSnapshot { + self.selection_session.lock().clone() + } + + pub fn publish_selection_session( + &self, + app: &AppHandle, + snapshot: SelectionSessionSnapshot, + ) -> Result { + let published = publish_selection_snapshot(&self.selection_session, snapshot)?; + app.emit("selection:changed", &published) + .map_err(|error| error.to_string())?; + Ok(published) + } + + pub(crate) fn plugin_authority(&self) -> &PluginRuntimeAuthority { + &self.plugin_authority + } + + pub(crate) fn plugin_rpc_router(&self) -> &PluginRpcRouter { + &self.plugin_rpc_router + } + + pub(crate) fn reset_plugin_authority(&self) -> Result, String> { + self.plugin_authority.reset(&self.plugin_rpc_router) + } + + pub fn mark_plugin_authority_unavailable(&self) { + self.plugin_authority + .mark_unavailable(&self.plugin_rpc_router); + } + + pub fn show_panel_window(&self, app: &AppHandle, view_state: PanelViewState) -> Result<()> { + let _creation_guard = self.panel_creation_lock.lock(); + *self.panel_view_state.lock() = Some(TargetedPanelViewState { + target: PanelViewTarget::Panel, + view_state, + }); + *self.panel_destroy_reason.lock() = None; + let result = if let Some(window) = app.get_webview_window(PANEL_LABEL) { + let _ = window.unminimize(); + window + .show() + .and_then(|()| window.set_focus()) + .map_err(anyhow::Error::from) + .and_then(|()| { + publish_panel_visibility_transition(&self.panel_visible, app, true, None) + }) + } else { + self.create_panel_window(app).and_then(|_| { + publish_panel_visibility_transition(&self.panel_visible, app, true, None) + }) + }; + if result.is_err() && app.get_webview_window(PANEL_LABEL).is_none() { + clear_targeted_panel_view_state( + &mut self.panel_view_state.lock(), + PanelViewTarget::Panel, + ); + } + result + } + + pub fn take_panel_view_state(&self, window_label: &str) -> Option { + take_targeted_panel_view_state(&mut self.panel_view_state.lock(), window_label) + } + + pub fn close_panel_window(&self, app: &AppHandle, view_state: PanelViewState) -> Result<()> { + let _creation_guard = self.panel_creation_lock.lock(); + *self.panel_view_state.lock() = Some(TargetedPanelViewState { + target: PanelViewTarget::Main, + view_state, + }); + *self.panel_close_request.lock() = PanelCloseRequestState::Closing; + let result = self.close_panel_window_inner(app, PanelVisibilityReason::Closed); + finish_panel_close(&self.panel_close_request); + if result.is_err() && app.get_webview_window(PANEL_LABEL).is_some() { + clear_targeted_panel_view_state( + &mut self.panel_view_state.lock(), + PanelViewTarget::Main, + ); + } + result + } + + fn close_panel_window_inner( + &self, + app: &AppHandle, + reason: PanelVisibilityReason, + ) -> Result<()> { + *self.panel_destroy_reason.lock() = Some(reason); + let mut bounds_error = None; + if let Some(window) = app.get_webview_window(PANEL_LABEL) { + if let Err(error) = + defer_panel_bounds_from_window(&window, &self.store, &self.panel_bounds_generation) + { + bounds_error = Some(error); + } + if let Err(error) = + flush_deferred_panel_bounds(&self.store, &self.panel_bounds_generation) + { + bounds_error.get_or_insert(error); + } + if let Err(error) = window.destroy() { + self.clear_panel_destroy_reason(reason); + return Err(error.into()); + } + } + self.publish_panel_hidden(app, reason)?; + if let Some(error) = bounds_error { + return Err(error); + } + Ok(()) + } + + fn publish_panel_hidden( + &self, + app: &AppHandle, + fallback_reason: PanelVisibilityReason, + ) -> Result<()> { + publish_panel_hidden_transition( + &self.panel_visible, + &self.panel_destroy_reason, + app, + fallback_reason, + ) + } + + fn clear_panel_destroy_reason(&self, reason: PanelVisibilityReason) { + let mut pending = self.panel_destroy_reason.lock(); + if *pending == Some(reason) { + *pending = None; + } + } + + pub fn acknowledge_panel_window_close(&self, request_id: &str) -> bool { + acknowledge_panel_close_request(&self.panel_close_request, request_id) + } + + pub fn is_panel_window_open(&self, app: &AppHandle) -> bool { + app.get_webview_window(PANEL_LABEL).is_some() + } + + pub fn capture_and_flush_panel_bounds_for_lifecycle(&self, app: &AppHandle) -> Result<()> { + if self.shutdown_started.load(Ordering::SeqCst) { + return Ok(()); + } + let _creation_guard = self.panel_creation_lock.lock(); + let Some(window) = app.get_webview_window(PANEL_LABEL) else { + return Ok(()); + }; + defer_panel_bounds_from_window(&window, &self.store, &self.panel_bounds_generation)?; + flush_deferred_panel_bounds(&self.store, &self.panel_bounds_generation) + } + + pub fn handle_panel_window_destroyed(&self, app: &AppHandle) { + self.plugin_rpc_router.remove_window(PANEL_LABEL); + clear_targeted_panel_view_state(&mut self.panel_view_state.lock(), PanelViewTarget::Panel); + finish_panel_close(&self.panel_close_request); + if let Err(error) = self.publish_panel_hidden(app, PanelVisibilityReason::Destroyed) { + log::warn!("failed to emit destroyed panel visibility: {error}"); + } + } + + fn create_panel_window(&self, app: &AppHandle) -> Result { + let monitor_data = MonitorData::gather(app); + let snapshot = self.store.snapshot(); + let stored_bounds = snapshot.panel_bounds; + let layout = resolve_panel_window_layout(stored_bounds, &monitor_data, None); + + let mut builder = WebviewWindowBuilder::new(app, PANEL_LABEL, WebviewUrl::App(PANEL_ENTRYPOINT.into())) + .title("DM Note - Panel") + // 메인·오버레이와 같은 프레임리스 크롬 - 드래그 영역은 패널 상단 스트립이 담당 + .decorations(false) + .transparent(true) + .shadow(true) + .resizable(true) + .maximizable(false) + .always_on_top(false) + .skip_taskbar(false) + .focused(false) + // 비포커스 상태의 첫 클릭이 포커스 획득에만 소비되지 않게 함 + // (유틸리티 패널 관례 - 버튼이 첫 클릭에 바로 동작) + .accept_first_mouse(true) + .visible(true) + .inner_size(PANEL_WIDTH, layout.height) + .min_inner_size(PANEL_WIDTH, PANEL_MIN_HEIGHT) + .max_inner_size(PANEL_WIDTH, layout.max_height) + .zoom_hotkeys_enabled(false); + + if let Some(position) = layout.position { + builder = builder.position(position.x, position.y); + } + + let window = builder.build().context("failed to create panel window")?; + + if let Some(position) = layout.position { + if let Err(err) = window.set_position(LogicalPosition::new(position.x, position.y)) { + log::warn!("failed to restore panel position after build: {err}"); + } + } + + if snapshot.developer_mode_enabled { + window.open_devtools(); + } + + self.configure_panel_window(&window, app, layout.max_height); + Ok(window) + } + + fn configure_panel_window( + &self, + window: &WebviewWindow, + app: &AppHandle, + initial_max_height: f64, + ) { + let store = self.store.clone(); + let bounds_generation = self.panel_bounds_generation.clone(); + let panel_window = window.clone(); + let app_handle = app.clone(); + let panel_max_height = Arc::new(Mutex::new(Some(initial_max_height))); + let panel_bounds_settle = Arc::new(Mutex::new(PanelBoundsSettleState { + latest: panel_bounds_sample_from_window(window).ok(), + generation: 0, + worker_running: false, + active: true, + })); + + window.on_window_event(move |event| match event { + WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + if let Err(err) = + defer_panel_bounds_from_window(&panel_window, &store, &bounds_generation) + { + log::warn!("failed to capture panel bounds on close request: {err}"); + } + let Some(state) = app_handle.try_state::() else { + return; + }; + let request_id = uuid::Uuid::new_v4().to_string(); + if !begin_panel_close_request(&state.panel_close_request, &request_id) { + return; + } + let payload = PanelCloseRequestedPayload { + request_id: request_id.clone(), + }; + if let Err(err) = app_handle.emit("panel:close-requested", &payload) { + log::warn!("failed to emit panel close request: {err}"); + } + let timeout_app = app_handle.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(PANEL_CLOSE_ACK_TIMEOUT).await; + let Some(state) = timeout_app.try_state::() else { + return; + }; + let _creation_guard = state.panel_creation_lock.lock(); + if let Err(error) = + run_panel_close_timeout(&state.panel_close_request, &request_id, || { + state.close_panel_window_inner( + &timeout_app, + PanelVisibilityReason::Destroyed, + ) + }) + { + log::warn!("failed to close panel after missing ack: {error}"); + } + }); + } + WindowEvent::Moved(position) => { + schedule_panel_bounds_settle( + &panel_window, + &store, + &bounds_generation, + &panel_max_height, + &panel_bounds_settle, + PanelBoundsChange::Moved(*position), + ); + } + WindowEvent::Resized(size) => { + schedule_panel_bounds_settle( + &panel_window, + &store, + &bounds_generation, + &panel_max_height, + &panel_bounds_settle, + PanelBoundsChange::Resized(*size), + ); + } + WindowEvent::ScaleFactorChanged { + scale_factor, + new_inner_size, + .. + } => { + schedule_panel_bounds_settle( + &panel_window, + &store, + &bounds_generation, + &panel_max_height, + &panel_bounds_settle, + PanelBoundsChange::ScaleFactorChanged { + position: panel_window.outer_position().ok(), + size: *new_inner_size, + scale_factor: *scale_factor, + }, + ); + } + WindowEvent::Destroyed => { + panel_bounds_settle.lock().active = false; + } + _ => {} + }); + } + fn ensure_overlay_window(&self, app: &AppHandle) -> Result { if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { return Ok(window); @@ -1570,8 +2871,10 @@ impl AppState { // Windows 접근성 텍스트 크기 설정에 의한 WebView2 스케일링을 보상 let zoom = crate::compute_compensating_zoom(); - if let Err(err) = window.set_zoom(zoom) { - log::warn!("failed to set overlay compensating zoom: {err}"); + if crate::should_apply_compensating_zoom(zoom) { + if let Err(err) = window.set_zoom(zoom) { + log::warn!("failed to set overlay compensating zoom: {err}"); + } } // macOS 오버레이 창 포커스 탈취 방지 @@ -1606,8 +2909,6 @@ impl AppState { } let _ = window.set_maximizable(false); - self.overlay_force_close.store(false, Ordering::SeqCst); - self.configure_overlay_window(&window, app); // 위치가 보정되었거나 logical 플래그 동기화가 필요한 경우에만 store 갱신 @@ -1649,7 +2950,7 @@ impl AppState { window.on_window_event(move |event| match event { WindowEvent::CloseRequested { api, .. } => { - if force_close_flag.swap(false, Ordering::SeqCst) { + if force_close_flag.load(Ordering::SeqCst) { // 앱 종료 시 — 실제 close 허용 *overlay_visible.write() = false; } else { @@ -1814,6 +3115,9 @@ impl AppState { if let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) { overlay.open_devtools(); } + if let Some(panel) = app.get_webview_window(PANEL_LABEL) { + panel.open_devtools(); + } } } @@ -1850,11 +3154,27 @@ impl AppState { if !self.key_counter_enabled.load(Ordering::Relaxed) { return None; } + { + let mut barrier = self.counter_history_barrier.lock(); + if barrier.queueing { + barrier.queued.push_back(QueuedCounterIncrement { + mode: mode.to_string(), + key: key.to_string(), + }); + return None; + } + barrier.active_increments = barrier.active_increments.saturating_add(1); + } let mut counters = self.key_counters.write(); let mode_entry = counters.entry(mode.to_string()).or_default(); let count = mode_entry.entry(key.to_string()).or_insert(0); *count = count.saturating_add(1); let count = *count; + let publication_generation = self.store.runtime_publication_generation(); + let mut publication = self.runtime_publication.lock(); + publication.counters_generation = + publication.counters_generation.max(publication_generation); + drop(publication); log::trace!( "[IPC] emit keys:counter: mode={}, key={}, count={}", mode, @@ -1866,6 +3186,11 @@ impl AppState { if let Err(err) = emit_result { error!("failed to emit keys:counter event: {err}"); } + let mut barrier = self.counter_history_barrier.lock(); + barrier.active_increments = barrier.active_increments.saturating_sub(1); + if barrier.active_increments == 0 { + self.counter_history_ready.notify_all(); + } Some(count) } @@ -1873,37 +3198,103 @@ impl AppState { self.key_counters.read().clone() } - /// 카운터 변경과 전체 emit의 단일 write lock 경계 - fn with_key_counters_write_and_emit( + pub(crate) fn begin_counter_history_barrier(&self) { + let mut barrier = self.counter_history_barrier.lock(); + debug_assert!(!barrier.queueing, "counter history barrier already active"); + barrier.queueing = true; + while barrier.active_increments != 0 { + self.counter_history_ready.wait(&mut barrier); + } + } + + pub(crate) fn lock_key_counters_for_history( &self, - emitter: &dyn KeyCounterEventEmitter, - updater: impl FnOnce(&mut KeyCounters) -> Result, - ) -> Result { - let mut guard = self.key_counters.write(); - let result = updater(&mut guard)?; - emitter.emit_key_counters(&guard)?; - Ok(result) + ) -> parking_lot::RwLockWriteGuard<'_, KeyCounters> { + self.key_counters.write() } - fn update_key_counters_and_emit( + pub(crate) fn replace_history_counters_locked( &self, - emitter: &dyn KeyCounterEventEmitter, - updater: impl FnOnce(&mut KeyCounters), - ) -> Result { - self.with_key_counters_write_and_emit(emitter, |guard| { - let mut scratch = guard.clone(); - updater(&mut scratch); - let persisted = self.store.set_key_counters(scratch)?; + guard: &mut KeyCounters, + generation: u64, + counters: &KeyCounters, + ) -> bool { + let mut publication = self.runtime_publication.lock(); + if generation <= publication.counters_generation { + return false; + } + *guard = counters.clone(); + publication.counters_generation = generation; + true + } + + pub(crate) fn finish_counter_history_barrier( + &self, + emitter: &dyn KeyCounterEventEmitter, + mut counters: parking_lot::RwLockWriteGuard<'_, KeyCounters>, + counters_restored: bool, + publication_generation: u64, + ) { + let queued_count = { + let mut barrier = self.counter_history_barrier.lock(); + let queued_count = barrier.queued.len(); + while let Some(increment) = barrier.queued.pop_front() { + let count = counters + .entry(increment.mode) + .or_default() + .entry(increment.key) + .or_insert(0); + *count = count.saturating_add(1); + } + barrier.queueing = false; + queued_count + }; + if queued_count != 0 { + let mut publication = self.runtime_publication.lock(); + publication.counters_generation = + publication.counters_generation.max(publication_generation); + } + if counters_restored || queued_count != 0 { + if let Err(error) = emitter.emit_key_counters(&counters) { + log::error!("failed to emit restored key counters: {error:#}"); + } + } + } + + fn update_key_counters_and_emit( + &self, + emitter: &dyn KeyCounterEventEmitter, + observed_history_epoch: Option, + updater: impl FnOnce(&mut KeyCounters), + ) -> std::result::Result { + let admission = self.store.admit_editor_mutation()?; + let mut guard = self.key_counters.write(); + let before = guard.clone(); + let mut scratch = before.clone(); + updater(&mut scratch); + let (persisted, history_status, publication_generation) = self + .store + .commit_key_counters_admitted(before, scratch, observed_history_epoch, &admission)?; + let mut publication = self.runtime_publication.lock(); + if publication_generation > publication.counters_generation { *guard = persisted.clone(); - Ok(persisted) + let emit_result = emitter.emit_key_counters(&guard); + publication.counters_generation = publication_generation; + emit_result.map_err(|error| EditorCommitError::io(error.to_string()))?; + } + Ok(AdmittedCounterMutation { + counters: persisted, + history_status, + _admission: admission, }) } pub(crate) fn reset_key_counters( &self, emitter: &dyn KeyCounterEventEmitter, - ) -> Result { - self.update_key_counters_and_emit(emitter, |counters| { + observed_history_epoch: Option, + ) -> std::result::Result { + self.update_key_counters_and_emit(emitter, observed_history_epoch, |counters| { for mode_entry in counters.values_mut() { for value in mode_entry.values_mut() { *value = 0; @@ -1916,11 +3307,10 @@ impl AppState { &self, emitter: &dyn KeyCounterEventEmitter, counters: KeyCounters, - keys: &KeyMappings, - ) -> Result { - self.update_key_counters_and_emit(emitter, |scratch| { + observed_history_epoch: Option, + ) -> std::result::Result { + self.update_key_counters_and_emit(emitter, observed_history_epoch, |scratch| { *scratch = counters; - Self::sync_counters_with_keys_impl(scratch, keys); }) } @@ -1928,8 +3318,9 @@ impl AppState { &self, emitter: &dyn KeyCounterEventEmitter, mode: &str, - ) -> Result { - self.update_key_counters_and_emit(emitter, |counters| { + observed_history_epoch: Option, + ) -> std::result::Result { + self.update_key_counters_and_emit(emitter, observed_history_epoch, |counters| { if let Some(entry) = counters.get_mut(mode) { for value in entry.values_mut() { *value = 0; @@ -1943,8 +3334,9 @@ impl AppState { emitter: &dyn KeyCounterEventEmitter, mode: &str, key: &str, - ) -> Result { - self.update_key_counters_and_emit(emitter, |counters| { + observed_history_epoch: Option, + ) -> std::result::Result { + self.update_key_counters_and_emit(emitter, observed_history_epoch, |counters| { if let Some(entry) = counters.get_mut(mode) { if let Some(value) = entry.get_mut(key) { *value = 0; @@ -1966,16 +3358,91 @@ impl AppState { pub(crate) fn apply_committed_editor_key_runtime( &self, emitter: &dyn KeyCounterEventEmitter, + generation: u64, keys: &KeyMappings, selected_key_type: &str, counters: &KeyCounters, ) -> Result<()> { - self.keyboard - .update_mappings_and_set_mode(keys.clone(), selected_key_type.to_string()); - self.with_key_counters_write_and_emit(emitter, |guard| { - *guard = counters.clone(); - Ok(()) - }) + let mut counter_guard = self.key_counters.write(); + self.apply_committed_editor_key_runtime_locked( + emitter, + &mut counter_guard, + generation, + keys, + selected_key_type, + counters, + ) + } + + pub(crate) fn apply_committed_editor_key_runtime_locked( + &self, + emitter: &dyn KeyCounterEventEmitter, + counter_guard: &mut KeyCounters, + generation: u64, + keys: &KeyMappings, + selected_key_type: &str, + counters: &KeyCounters, + ) -> Result<()> { + let mut publication = self.runtime_publication.lock(); + Self::apply_key_runtime_if_current( + &self.keyboard, + &mut publication, + generation, + keys, + selected_key_type, + ); + if generation <= publication.counters_generation { + return Ok(()); + } + *counter_guard = counters.clone(); + let emit_result = emitter.emit_key_counters(counter_guard); + publication.counters_generation = generation; + emit_result + } + + pub(crate) fn apply_committed_editor_keys_without_counters( + &self, + generation: u64, + keys: &KeyMappings, + selected_key_type: &str, + ) -> bool { + let mut publication = self.runtime_publication.lock(); + Self::apply_key_runtime_if_current( + &self.keyboard, + &mut publication, + generation, + keys, + selected_key_type, + ) + } + + fn apply_key_runtime_if_current( + keyboard: &KeyboardManager, + publication: &mut RuntimePublicationState, + generation: u64, + keys: &KeyMappings, + selected_key_type: &str, + ) -> bool { + let mappings_current = generation >= publication.mappings_generation; + let mode_current = generation >= publication.mode_generation + && generation >= publication.mappings_generation; + match (mappings_current, mode_current) { + (true, true) => { + keyboard.update_mappings_and_set_mode(keys.clone(), selected_key_type.to_string()); + } + (true, false) => keyboard.update_mappings(keys.clone()), + (false, true) => { + keyboard.set_mode(selected_key_type.to_string()); + } + (false, false) => {} + } + if mappings_current { + publication.mappings_generation = generation; + } + if mode_current { + publication.mode_generation = generation; + } + mode_current } fn sync_counters_with_keys_impl(target: &mut KeyCounters, keys: &KeyMappings) { @@ -2183,6 +3650,12 @@ impl AppState { watcher.unwatch_tab(tab_id); } } + + pub(crate) fn resync_tab_css_watchers(&self, overrides: &TabCssOverrides) { + if let Some(watcher) = self.css_watcher.read().as_ref() { + watcher.resync_tabs(overrides); + } + } } fn global_css_watch_path(state: &AppStoreData) -> Option<&str> { @@ -2274,18 +3747,7 @@ fn attach_main_window_close_handler( return; } - // 즉시 종료 효과를 위해 윈도우 먼저 숨김 - // 실제 정리/프로세스 종료는 백그라운드 스레드에서 수행 api.prevent_close(); - if let Err(err) = main_window.hide() { - log::warn!("failed to hide main window during shutdown: {err}"); - } - if let Some(overlay) = app_handle.get_webview_window(OVERLAY_LABEL) { - if let Err(err) = overlay.hide() { - log::warn!("failed to hide overlay window during shutdown: {err}"); - } - } - state.request_frontend_shutdown(app_handle.clone()); } }); @@ -2298,12 +3760,33 @@ fn execute_frontend_lifecycle( ) { match action { FrontendLifecycleAction::Quit => { - thread::spawn(move || shutdown_application(app_handle, overlay_force_close)); + if overlay_force_close.swap(true, Ordering::SeqCst) { + return; + } + thread::spawn(move || shutdown_application(app_handle)); } FrontendLifecycleAction::Restart => { + if overlay_force_close.swap(true, Ordering::SeqCst) { + return; + } let state = app_handle.state::(); state.arm_shutdown_watchdog("app-restart"); + if let Err(err) = state.capture_and_flush_panel_bounds_for_lifecycle(&app_handle) { + log::warn!("failed to persist panel bounds before restart: {err}"); + } state.shutdown(); + state.authorize_process_exit(); + #[cfg(target_os = "macos")] + match super::macos_termination::restart_after_canceling_pending_termination(&app_handle) + { + Ok(true) => return, + Ok(false) => {} + Err(error) => { + log::warn!( + "failed to cancel pending macOS termination before restart: {error}" + ); + } + } app_handle.request_restart(); } } @@ -2323,11 +3806,7 @@ fn dispatch_remove_tray_icon(app: &AppHandle) -> Result<()> { Ok(()) } -fn shutdown_application(app_handle: AppHandle, overlay_force_close: Arc) { - if overlay_force_close.swap(true, Ordering::SeqCst) { - return; - } - +fn shutdown_application(app_handle: AppHandle) { app_handle .state::() .arm_shutdown_watchdog("background state shutdown"); @@ -2344,6 +3823,9 @@ fn shutdown_application(app_handle: AppHandle, overlay_force_close: Arc