diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index d3c58871..585e5ee5 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -85,6 +85,8 @@ firmware that violates those barriers. interface EditorCommitRequest { baseRevision: number; mutationId: string; + gestureId?: string; + gestureIds?: string[]; changes: EditorPatchV1; } @@ -113,6 +115,12 @@ intended for immediate IPC retries; the in-memory deduplication window does not survive an app restart. Reusing a retained ID for a different request is rejected. +`gestureId` is the representative history gesture. `gestureIds` carries every +preview session coalesced into the request so the committed event can echo the +complete set. Both fields are optional and accept UUIDs only. The `gestureIds` +array can contain at most 32 entries, and the combined unique set across both +fields is also limited to 32 IDs. + If the submitted values are already current, the result keeps the current revision, returns an empty `changedFields`, and emits no canonical committed event. Compatibility wrappers may still project their legacy per-field refresh @@ -153,6 +161,8 @@ the human-readable `message`. type EditorCommitErrorCode = | 'REVISION_CONFLICT' | 'VALIDATION_FAILED' + | 'TOO_MANY_GESTURE_IDS' + | 'INVALID_GESTURE_ID' | 'PAIRED_UPDATE_REQUIRED' | 'MUTATION_ID_REUSED' | 'IO_ERROR'; @@ -169,13 +179,15 @@ interface EditorCommitError { } ``` -| Code | Meaning | `retryable` | -| ------------------------ | ---------------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | -| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | -| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | -| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | -| `IO_ERROR` | The document could not be persisted | `true` | +| Code | Meaning | `retryable` | +| ------------------------ | ------------------------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | +| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | +| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | +| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | +| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | +| `IO_ERROR` | The document could not be persisted | `true` | `details.currentRevision`, `details.validationCode`, or `details.field` is included when it applies to the error. @@ -192,6 +204,8 @@ interface EditorCommittedV1 { schemaVersion: 1; revision: number; mutationId: string; + gestureId?: string | null; + gestureIds?: string[]; origin?: string; changedFields: EditorField[]; patch: EditorPatchV1; diff --git a/docs/content/en/declarative-api/page.mdx b/docs/content/en/declarative-api/page.mdx index 186c2199..e28fba9a 100644 --- a/docs/content/en/declarative-api/page.mdx +++ b/docs/content/en/declarative-api/page.mdx @@ -309,6 +309,7 @@ Use `resizable` option to let users resize elements directly in the grid. ### resizable Set `resizable: true` to show 8-direction resize handles: +The initial size is 200×150. A saved or estimated size takes precedence when available. ```javascript dmn.plugin.defineElement({ diff --git a/docs/content/en/guide/installation/page.mdx b/docs/content/en/guide/installation/page.mdx index 72c0a7de..49651d73 100644 --- a/docs/content/en/guide/installation/page.mdx +++ b/docs/content/en/guide/installation/page.mdx @@ -9,7 +9,7 @@ description: How to download, install, and run DM Note DM Note can be downloaded from GitHub Releases. -1. Download the latest version from the [GitHub Releases page](https://github.com/lee-sihun/DmNote/releases). +1. Download the latest version from the [GitHub Releases page](https://github.com/DmNote-App/DmNote/releases). 2. Extract the downloaded ZIP file to your desired location. 3. Run the `DM Note.exe` file. diff --git a/docs/content/en/guide/settings/page.mdx b/docs/content/en/guide/settings/page.mdx index 4543913c..3331b11e 100644 --- a/docs/content/en/guide/settings/page.mdx +++ b/docs/content/en/guide/settings/page.mdx @@ -39,6 +39,13 @@ When enabled, the overlay window ignores mouse events (click-through) allowing y When locked, you cannot directly interact with the overlay. +### Detaching the Properties Panel + +Use the detach button at the top of the properties panel to move it into a separate window at any time. Key, note, and counter editing as well as layer management work the same in the detached window. + +- Use the X button on the detached window or the reattach button at the top of the panel to return it inline. +- While detached, picking a gradient color anchor on the canvas is limited. + ## Graphics Settings ### Rendering Option diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 8e1c447e..84e1f38e 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -75,15 +75,17 @@ console.log(revision, document.keyPositions); 고장까지 애플리케이션이 절대 보장할 수는 없습니다. - 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, - 프리셋 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. - 이 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. + 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋 + 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. 이 + 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. ```typescript interface EditorCommitRequest { baseRevision: number; mutationId: string; + gestureId?: string; + gestureIds?: string[]; changes: EditorPatchV1; } @@ -111,6 +113,11 @@ console.log('커밋된 revision:', result.revision); 방지는 즉시 IPC 재시도용이며 앱을 재시작하면 유지되지 않습니다. 아직 보관 중인 ID를 다른 요청에 재사용하면 거절됩니다. +`gestureId`는 히스토리 병합의 대표 게스처입니다. `gestureIds`는 요청 하나로 +합쳐진 모든 프리뷰 세션을 전달하여 committed 이벤트가 전체 집합을 echo하게 +합니다. 두 필드는 모두 선택 사항이며 UUID만 허용합니다. `gestureIds` 배열은 +항목이 최대 32개이고, 두 필드를 합친 고유 ID 집합도 최대 32개로 제한됩니다. + 제출한 값이 이미 현재 값과 같으면 현재 revision과 빈 `changedFields`를 반환하고 canonical committed 이벤트는 발행하지 않습니다. 호환 wrapper는 예전 필드별 refresh 이벤트를 한 번 투영할 수 있지만, 같은 `mutationId` 재시도에서는 @@ -151,6 +158,8 @@ await dmn.editor.commit({ type EditorCommitErrorCode = | 'REVISION_CONFLICT' | 'VALIDATION_FAILED' + | 'TOO_MANY_GESTURE_IDS' + | 'INVALID_GESTURE_ID' | 'PAIRED_UPDATE_REQUIRED' | 'MUTATION_ID_REUSED' | 'IO_ERROR'; @@ -167,13 +176,15 @@ interface EditorCommitError { } ``` -| 코드 | 의미 | `retryable` | -| ------------------------ | ------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | -| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | -| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | -| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | -| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | +| 코드 | 의미 | `retryable` | +| ------------------------ | --------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | +| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` | +| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` | +| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | +| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | +| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | 오류에 해당할 때 `details.currentRevision`, `details.validationCode`, `details.field`가 함께 제공됩니다. @@ -190,6 +201,8 @@ interface EditorCommittedV1 { schemaVersion: 1; revision: number; mutationId: string; + gestureId?: string | null; + gestureIds?: string[]; origin?: string; changedFields: EditorField[]; patch: EditorPatchV1; diff --git a/docs/content/ko/declarative-api/page.mdx b/docs/content/ko/declarative-api/page.mdx index 4cdf77f8..3e6a6837 100644 --- a/docs/content/ko/declarative-api/page.mdx +++ b/docs/content/ko/declarative-api/page.mdx @@ -308,6 +308,7 @@ dmn.plugin.defineElement({ ### resizable `resizable: true`로 설정하면 요소에 8방향 리사이즈 핸들이 표시됩니다. +첫 생성 크기는 200×150이며, 저장된 크기나 추정 크기가 있으면 해당 값을 우선합니다. ```javascript dmn.plugin.defineElement({ diff --git a/docs/content/ko/guide/installation/page.mdx b/docs/content/ko/guide/installation/page.mdx index e4d77302..658e5bdf 100644 --- a/docs/content/ko/guide/installation/page.mdx +++ b/docs/content/ko/guide/installation/page.mdx @@ -9,7 +9,7 @@ description: DM Note 다운로드, 설치 및 실행 방법 DM Note는 GitHub Releases에서 다운로드할 수 있습니다. -1. [GitHub Releases 페이지](https://github.com/lee-sihun/DmNote/releases)에서 최신 버전을 다운로드합니다. +1. [GitHub Releases 페이지](https://github.com/DmNote-App/DmNote/releases)에서 최신 버전을 다운로드합니다. 2. 다운로드한 ZIP 파일의 압축을 원하는 위치에 해제합니다. 3. `DM Note.exe` 파일을 실행합니다. diff --git a/docs/content/ko/guide/settings/page.mdx b/docs/content/ko/guide/settings/page.mdx index 324100a6..264b218a 100644 --- a/docs/content/ko/guide/settings/page.mdx +++ b/docs/content/ko/guide/settings/page.mdx @@ -38,6 +38,13 @@ DM Note의 다양한 설정 옵션을 안내합니다. 고정을 켠 상태에서는 오버레이를 직접 조작할 수 없습니다. +### 속성 패널 분리 + +캔버스 우측 속성 패널의 상단 분리 버튼을 누르면 언제든 별도 창으로 분리할 수 있습니다. 분리된 창에서도 키·노트·카운터 편집과 레이어 관리를 동일하게 사용할 수 있습니다. + +- 분리 창의 X 버튼 또는 패널 상단의 되돌리기 버튼으로 다시 인라인 패널로 복귀합니다. +- 분리 상태에서는 그라디언트 색상의 캔버스 앵커 지정이 제한됩니다. + ## 그래픽 설정 ### 렌더링 옵션 diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 85c000df..05ffef14 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_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 +{"__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","commit_gesture","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/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 85830e11..378871dc 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -13,6 +13,7 @@ "app_quit", "app_quit_after_editor_flush", "app_restart", + "commit_gesture", "counter_animation_create", "counter_animation_delete", "counter_animation_list", diff --git a/src-tauri/src/commands/app/update.rs b/src-tauri/src/commands/app/update.rs index ccf9525e..b92798a6 100644 --- a/src-tauri/src/commands/app/update.rs +++ b/src-tauri/src/commands/app/update.rs @@ -56,7 +56,7 @@ fn app_auto_update_windows( ) -> CmdResult { use std::time::Duration; - const REPO_OWNER: &str = "lee-sihun"; + const REPO_OWNER: &str = "DmNote-App"; const REPO_NAME: &str = "DmNote"; const ASSET_NAME: &str = "DM.NOTE.exe"; const SIGNATURE_ASSET_NAME: &str = "DM.NOTE.exe.sig"; diff --git a/src-tauri/src/commands/editor/gesture.rs b/src-tauri/src/commands/editor/gesture.rs new file mode 100644 index 00000000..941baa73 --- /dev/null +++ b/src-tauri/src/commands/editor/gesture.rs @@ -0,0 +1,90 @@ +use tauri::{AppHandle, State, WebviewWindow}; + +use crate::{ + commands::{ + editor::state::{emit_best_effort, publish_editor_change, publish_legacy_editor_fields}, + plugin::instances::publish_plugin_instances_changed, + }, + errors::{CmdResult, EditorCommitError}, + models::{GestureCommitRequest, GestureCommitResult, PluginInstancesChangedPayload}, + services::preview_broker::PreviewBroker, + state::{gesture::validate_gesture_commit_request, AppState}, +}; + +const MAIN_WINDOW_LABEL: &str = "main"; + +#[tauri::command] +pub fn commit_gesture( + state: State<'_, AppState>, + broker: State<'_, PreviewBroker>, + app: AppHandle, + window: WebviewWindow, + request: GestureCommitRequest, +) -> CmdResult { + validate_gesture_commit_request(&request)?; + if window.label() != MAIN_WINDOW_LABEL { + return Err(crate::errors::CommandError::msg( + "GESTURE_MUTATION_NOT_ALLOWED", + )); + } + + let authority = state + .plugin_authority() + .admit(request.authority_generation) + .map_err(crate::errors::CommandError::msg)?; + let admission = state + .admit_frontend_history_mutation(window.label()) + .map_err(|_| EditorCommitError::history_in_progress())?; + let mutation_id = request.mutation_id.clone(); + let gesture_id = request.gesture_id.clone(); + let requested_fields = request + .editor_changes + .as_ref() + .map_or_else(Vec::new, |changes| changes.included_fields()); + let previous_mode = requested_fields + .contains(&crate::models::EditorField::Keys) + .then(|| state.keyboard.current_mode()); + + let committed = state + .store + .commit_gesture_with_admission(request, admission)?; + let outcome = &committed.outcome; + if let Err(error) = + broker.finish_committed_session(window.label(), &gesture_id, outcome.change.is_none()) + { + log::warn!("failed to finish committed gesture preview session: {error}"); + } + + if let Some(change) = outcome.change.as_ref() { + publish_editor_change(state.inner(), &app, change, false); + if !outcome.replayed { + publish_legacy_editor_fields(state.inner(), &app, change, &requested_fields); + if previous_mode.is_some_and(|mode| mode != change.selected_key_type) { + emit_best_effort( + &app, + "keys:mode-changed", + &serde_json::json!({ "mode": &change.selected_key_type }), + ); + } + } + } + if !outcome.replayed { + for plugin_id in &outcome.changed_plugin_ids { + publish_plugin_instances_changed( + &app, + &PluginInstancesChangedPayload { + plugin_id: plugin_id.clone(), + revision: outcome.result.plugin_model_revision, + origin_mutation_id: Some(mutation_id.clone()), + }, + ); + } + } + if let Some(status) = outcome.history_status.as_ref() { + emit_best_effort(&app, "history:status", status); + } + + let mut result = outcome.result.clone(); + result.authority_generation = authority.generation(); + Ok(result) +} diff --git a/src-tauri/src/commands/editor/history.rs b/src-tauri/src/commands/editor/history.rs index c255f9a2..01b80399 100644 --- a/src-tauri/src/commands/editor/history.rs +++ b/src-tauri/src/commands/editor/history.rs @@ -144,6 +144,20 @@ fn run_history_operation( ); } Some(HistoryAuxChange::PluginElements { .. }) => {} + Some(HistoryAuxChange::PluginElementsBatch { .. }) => { + 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, + ); + } + } None => { if let Some(change) = committed_change.filter(|change| { change @@ -325,6 +339,21 @@ fn publish_history_aux_change( }, ); } + Some(HistoryAuxChange::PluginElementsBatch { + plugin_ids, + revision, + }) => { + for plugin_id in plugin_ids { + publish_plugin_instances_changed( + app, + &PluginInstancesChangedPayload { + plugin_id: plugin_id.clone(), + revision: *revision, + origin_mutation_id: None, + }, + ); + } + } None => {} } } diff --git a/src-tauri/src/commands/editor/mod.rs b/src-tauri/src/commands/editor/mod.rs index b4d43bd5..da586e2d 100644 --- a/src-tauri/src/commands/editor/mod.rs +++ b/src-tauri/src/commands/editor/mod.rs @@ -1,4 +1,5 @@ pub mod css; +pub mod gesture; pub mod history; pub mod js; pub mod note_tab; diff --git a/src-tauri/src/commands/editor/preview.rs b/src-tauri/src/commands/editor/preview.rs index 9298703a..d00efe6e 100644 --- a/src-tauri/src/commands/editor/preview.rs +++ b/src-tauri/src/commands/editor/preview.rs @@ -25,7 +25,6 @@ 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) + broker.cancel(window.label(), &session_id) } diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs index 41f81f38..4aa6f8c6 100644 --- a/src-tauri/src/commands/editor/state.rs +++ b/src-tauri/src/commands/editor/state.rs @@ -200,7 +200,7 @@ pub fn editor_commit( 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 gesture_ids = request.echoed_gesture_ids(); let requested_fields = request.changes.included_fields(); let previous_mode = requested_fields .contains(&EditorField::Keys) @@ -208,6 +208,13 @@ pub fn editor_commit( let change = state .store .commit_editor_document_admitted(request, &admission)?; + for gesture_id in &gesture_ids { + if let Err(error) = + broker.finish_committed_session(window.label(), gesture_id, change.event.is_none()) + { + log::warn!("failed to finish committed preview session: {error}"); + } + } if change.event.is_some() { publish_editor_change(state.inner(), &app, &change, false); } @@ -221,15 +228,6 @@ 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); } diff --git a/src-tauri/src/commands/layout/panel.rs b/src-tauri/src/commands/layout/panel.rs index 18d24987..8186c212 100644 --- a/src-tauri/src/commands/layout/panel.rs +++ b/src-tauri/src/commands/layout/panel.rs @@ -120,7 +120,13 @@ fn start_panel_window_dragging( } else { let modifier_flags = NSEvent::modifierFlags(current_event); let timestamp = NSEvent::timestamp(current_event); - let event_number = NSEvent::eventNumber(current_event); + // eventNumber는 마우스 계열 이벤트에만 유효, 키 이벤트 등에 조회하면 NSInternalInconsistencyException + let event_type: NSUInteger = msg_send![current_event, type]; + let event_number = if is_mouse_event_type(event_type) { + NSEvent::eventNumber(current_event) + } else { + 0 + }; (modifier_flags, timestamp, event_number) }; @@ -176,9 +182,24 @@ fn is_panel_mouse_down_event( event_type == 1 && event_window_number == target_window_number } +// NSLeftMouseDown(1)~NSMouseExited(9), NSOtherMouse*(25~27) +fn is_mouse_event_type(event_type: u64) -> bool { + matches!(event_type, 1..=9 | 25..=27) +} + #[cfg(test)] mod tests { - use super::{is_panel_mouse_down_event, panel_drag_local_coordinates}; + use super::{is_mouse_event_type, is_panel_mouse_down_event, panel_drag_local_coordinates}; + + #[test] + fn panel_drag_synthetic_event_borrows_event_number_from_mouse_events_only() { + assert!(is_mouse_event_type(1)); + assert!(is_mouse_event_type(9)); + assert!(is_mouse_event_type(26)); + assert!(!is_mouse_event_type(10)); // NSKeyDown + assert!(!is_mouse_event_type(11)); // NSKeyUp + assert!(!is_mouse_event_type(12)); // NSFlagsChanged + } #[test] fn panel_drag_reuses_only_the_target_windows_mouse_down_event() { diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 705559df..34c441a9 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -4,7 +4,10 @@ use serde::Serialize; #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum EditorCommitErrorCode { RevisionConflict, + PluginRevisionConflict, ValidationFailed, + TooManyGestureIds, + InvalidGestureId, PairedUpdateRequired, MutationIdReused, HistoryInProgress, @@ -48,6 +51,17 @@ impl EditorCommitError { } } + pub fn plugin_revision_conflict(current_plugin_model_revision: u64) -> Self { + Self { + error_code: EditorCommitErrorCode::PluginRevisionConflict, + message: format!( + "plugin model revision conflict at revision {current_plugin_model_revision}" + ), + details: None, + retryable: true, + } + } + pub fn validation(validation_code: impl Into, message: impl Into) -> Self { Self { error_code: EditorCommitErrorCode::ValidationFailed, @@ -60,6 +74,24 @@ impl EditorCommitError { } } + pub fn too_many_gesture_ids(max: usize) -> Self { + Self { + error_code: EditorCommitErrorCode::TooManyGestureIds, + message: format!("gesture ID count exceeds {max}"), + details: None, + retryable: false, + } + } + + pub fn invalid_gesture_id() -> Self { + Self { + error_code: EditorCommitErrorCode::InvalidGestureId, + message: "gesture IDs must be UUIDs no longer than 64 bytes".to_string(), + details: None, + retryable: false, + } + } + pub fn paired_update_required(field: impl Into) -> Self { let field = field.into(); Self { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 854cc0e2..12476a37 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -217,6 +217,7 @@ fn main() { commands::editor::note_tab::note_tab_clear, commands::editor::state::editor_get, commands::editor::state::editor_commit, + commands::editor::gesture::commit_gesture, commands::editor::history::history_status, commands::editor::history::history_undo, commands::editor::history::history_redo, diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 8c4a2e1c..8298b886 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -188,9 +188,30 @@ pub struct EditorCommitRequest { pub mutation_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub gesture_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gesture_ids: Vec, pub changes: EditorPatchV1, } +impl EditorCommitRequest { + pub fn echoed_gesture_ids(&self) -> Vec { + let mut gesture_ids = + Vec::with_capacity(self.gesture_ids.len() + usize::from(self.gesture_id.is_some())); + for gesture_id in self.gesture_ids.iter().chain(self.gesture_id.iter()) { + if !gesture_ids.contains(gesture_id) { + gesture_ids.push(gesture_id.clone()); + } + } + gesture_ids + } + + pub fn history_gesture_id(&self) -> Option { + self.gesture_id + .clone() + .or_else(|| self.gesture_ids.last().cloned()) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorCommitResult { @@ -232,6 +253,8 @@ pub struct EditorCommittedV1 { pub mutation_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub gesture_id: Option, + #[serde(default)] + pub gesture_ids: Vec, pub origin: String, pub changed_fields: Vec, pub patch: EditorPatchV1, @@ -240,6 +263,7 @@ pub struct EditorCommittedV1 { #[derive(Debug, Clone, PartialEq, Eq)] pub enum EditorCommitOrigin { StrictEditorCommit, + GestureCommit, LegacyAdapter(String), HistoryUndo, HistoryRedo, @@ -252,6 +276,7 @@ impl EditorCommitOrigin { pub fn event_name(&self) -> Option { match self { Self::StrictEditorCommit => Some("editorCommit".to_string()), + Self::GestureCommit => Some("gestureCommit".to_string()), Self::LegacyAdapter(command) => Some(format!("legacy:{command}")), Self::HistoryUndo => Some("historyUndo".to_string()), Self::HistoryRedo => Some("historyRedo".to_string()), diff --git a/src-tauri/src/models/gesture.rs b/src-tauri/src/models/gesture.rs new file mode 100644 index 00000000..3246072e --- /dev/null +++ b/src-tauri/src/models/gesture.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +use super::{EditorField, EditorPatchV1, SavedPluginInstance}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GesturePluginInstancesChange { + pub plugin_id: String, + pub instances: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GestureCommitRequest { + pub gesture_id: String, + pub mutation_id: String, + pub editor_base_revision: u64, + pub plugin_base_revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_history_epoch: Option, + pub authority_generation: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub editor_changes: Option, + pub plugin_changes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GestureCommitResult { + pub editor_revision: u64, + pub changed_fields: Vec, + pub plugin_model_revision: u64, + pub changed_plugin_ids: Vec, + pub authority_generation: u64, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 7759d109..093b2dbf 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,8 +1,10 @@ pub mod editor; +pub mod gesture; pub mod obs; pub mod plugin; pub use editor::*; +pub use gesture::*; pub use plugin::*; use serde::de::Error as DeError; diff --git a/src-tauri/src/models/plugin.rs b/src-tauri/src/models/plugin.rs index aabc6858..d021e1d0 100644 --- a/src-tauri/src/models/plugin.rs +++ b/src-tauri/src/models/plugin.rs @@ -35,6 +35,10 @@ pub struct SavedPluginInstance { pub measured_size: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tab_id: Option, + #[serde(default)] + pub hidden: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub z_index: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 2934c805..350435f5 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -113,9 +113,6 @@ pub struct PreviewEnvelope { pub mode: String, pub targets: Vec, pub patch: Map, - // cancel 전용: 수신측이 이 revision 반영 후에만 세션을 제거하는 순서 게이트 - #[serde(default)] - pub min_revision: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -218,7 +215,6 @@ impl PreviewBroker { mode: request.mode, targets: request.targets, patch: request.patch, - min_revision: None, }; validate_payload_size(&envelope)?; @@ -302,12 +298,7 @@ impl PreviewBroker { cancelled } - pub fn cancel( - &self, - label: &str, - session_id: &str, - min_revision: Option, - ) -> Result<(), String> { + pub fn cancel(&self, label: &str, session_id: &str) -> Result<(), String> { validate_session_id(session_id)?; let (recipients, envelope) = { let mut state = self.state.lock(); @@ -339,9 +330,7 @@ impl PreviewBroker { }; 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) + (recipients, cancellation_envelope(session_id, &session)) }; send_envelopes(&recipients, &[envelope]); @@ -352,12 +341,12 @@ impl PreviewBroker { &self, label: &str, session_id: &str, - committed_revision: Option, + broadcast_cancel: bool, ) -> Result { if Uuid::parse_str(session_id).is_err() { return Ok(false); } - let (recipients, envelope) = { + let (recipients, cancellation) = { let mut state = self.state.lock(); if state.tombstones.contains(session_id) { return Ok(false); @@ -382,14 +371,19 @@ impl PreviewBroker { }, }; 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) + let recipients = if broadcast_cancel { + clone_channels(&state, Some(label)) + } else { + Vec::new() + }; + let cancellation = + broadcast_cancel.then(|| cancellation_envelope(session_id, &session)); + (recipients, cancellation) }; - send_envelopes(&recipients, &[envelope]); + if let Some(cancellation) = cancellation { + send_envelopes(&recipients, &[cancellation]); + } Ok(true) } @@ -501,7 +495,6 @@ fn cancellation_envelope(session_id: &str, session: &PreviewSession) -> PreviewE mode: String::new(), targets: Vec::new(), patch: Map::new(), - min_revision: None, } } @@ -574,7 +567,6 @@ mod tests { mode: request.mode, targets: request.targets, patch: request.patch, - min_revision: None, } } @@ -756,34 +748,42 @@ mod tests { } #[test] - fn successful_commit_ends_preview_session() { + fn committed_sessions_are_tombstoned_without_auxiliary_broadcast() { 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"); + let session_ids = vec![session_id(), session_id()]; + for session_id in &session_ids { + broker + .publish("owner", request(session_id, 1)) + .expect("publish succeeds"); + } - assert!(broker - .finish_committed_session("owner", &session_id, Some(7)) - .expect("commit cleanup succeeds")); + for session_id in &session_ids { + assert!(broker + .finish_committed_session("owner", session_id, false) + .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")); + for session_id in &session_ids { + assert!(broker + .publish("owner", request(session_id, 2)) + .unwrap_err() + .contains("already ended")); + } } #[test] - fn commit_before_first_publish_rejects_late_patch() { + fn no_op_commit_broadcasts_cancel_and_rejects_late_patch() { let broker = PreviewBroker::default(); subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); let session_id = session_id(); assert!(broker - .finish_committed_session("owner", &session_id, None) + .finish_committed_session("owner", &session_id, true) .expect("commit cleanup succeeds")); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); assert!(broker .publish("owner", request(&session_id, 1)) @@ -798,7 +798,7 @@ mod tests { let session_id = session_id(); broker - .cancel("owner", &session_id, None) + .cancel("owner", &session_id) .expect("early cancel succeeds"); assert!(broker diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index a1f089cd..db10c250 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -93,7 +93,7 @@ struct ShutdownWatchdogState { stage: &'static str, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] struct PanelBoundsSample { position: PhysicalPosition, position_scale_factor: f64, @@ -104,6 +104,7 @@ struct PanelBoundsSample { #[derive(Debug, Clone, Copy)] enum PanelBoundsChange { + Snapshot(PanelBoundsSample), Moved(PhysicalPosition), Resized(PhysicalSize), ScaleFactorChanged { @@ -113,14 +114,31 @@ enum PanelBoundsChange { }, } -#[derive(Debug)] -struct PanelBoundsSettleState { +#[derive(Default)] +struct PanelBoundsPersistenceState { latest: Option, + window: Option, + applied_max_height: Option, + session: u64, generation: u64, worker_running: bool, + dirty: bool, active: bool, } +#[derive(Debug, Clone, Copy, PartialEq)] +struct PanelBoundsPersistWork { + session: u64, + generation: u64, + sample: PanelBoundsSample, +} + +struct PanelBoundsPersistenceController { + store: Arc, + state: Mutex, + persist_lock: Mutex<()>, +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum FrontendLifecycleAction { @@ -763,7 +781,7 @@ pub struct AppState { selection_session: Mutex, plugin_authority: PluginRuntimeAuthority, plugin_rpc_router: PluginRpcRouter, - panel_bounds_generation: Arc, + panel_bounds_persistence: Arc, panel_visible: AtomicBool, panel_creation_lock: Mutex<()>, panel_close_request: Mutex, @@ -819,6 +837,8 @@ 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 panel_bounds_persistence = + Arc::new(PanelBoundsPersistenceController::new(Arc::clone(&store))); let selection_session = SelectionSessionSnapshot { mode: snapshot.selected_key_type.clone(), ..SelectionSessionSnapshot::default() @@ -836,7 +856,7 @@ impl AppState { selection_session: Mutex::new(selection_session), plugin_authority: PluginRuntimeAuthority::default(), plugin_rpc_router: PluginRpcRouter::default(), - panel_bounds_generation: Arc::new(AtomicU64::new(0)), + panel_bounds_persistence, panel_visible: AtomicBool::new(false), panel_creation_lock: Mutex::new(()), panel_close_request: Mutex::new(PanelCloseRequestState::Idle), @@ -2559,16 +2579,9 @@ impl AppState { *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) - { + if let Err(error) = self.panel_bounds_persistence.flush_now(&window) { 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()); @@ -2617,8 +2630,7 @@ impl AppState { 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) + self.panel_bounds_persistence.flush_now(&window) } pub fn handle_panel_window_destroyed(&self, app: &AppHandle) { @@ -2682,25 +2694,22 @@ impl AppState { app: &AppHandle, initial_max_height: f64, ) { - let store = self.store.clone(); - let bounds_generation = self.panel_bounds_generation.clone(); + let bounds_session = self + .panel_bounds_persistence + .attach(window, initial_max_height); + let bounds_persistence = Arc::clone(&self.panel_bounds_persistence); 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}"); + match panel_bounds_sample_from_window(&panel_window) { + Ok(sample) => bounds_persistence + .record_event(bounds_session, PanelBoundsChange::Snapshot(sample)), + Err(err) => { + log::warn!("failed to capture panel bounds on close request: {err}") + } } let Some(state) = app_handle.try_state::() else { return; @@ -2735,36 +2744,19 @@ impl AppState { }); } WindowEvent::Moved(position) => { - schedule_panel_bounds_settle( - &panel_window, - &store, - &bounds_generation, - &panel_max_height, - &panel_bounds_settle, - PanelBoundsChange::Moved(*position), - ); + bounds_persistence + .record_event(bounds_session, PanelBoundsChange::Moved(*position)); } WindowEvent::Resized(size) => { - schedule_panel_bounds_settle( - &panel_window, - &store, - &bounds_generation, - &panel_max_height, - &panel_bounds_settle, - PanelBoundsChange::Resized(*size), - ); + bounds_persistence.record_event(bounds_session, 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, + bounds_persistence.record_event( + bounds_session, PanelBoundsChange::ScaleFactorChanged { position: panel_window.outer_position().ok(), size: *new_inner_size, @@ -2773,7 +2765,7 @@ impl AppState { ); } WindowEvent::Destroyed => { - panel_bounds_settle.lock().active = false; + bounds_persistence.deactivate(bounds_session); } _ => {} }); @@ -4283,6 +4275,7 @@ fn panel_bounds_from_sample(sample: PanelBoundsSample) -> PanelBounds { fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBoundsChange) { match change { + PanelBoundsChange::Snapshot(snapshot) => *sample = snapshot, PanelBoundsChange::Moved(position) => { sample.position = position; sample.position_scale_factor = sample.current_scale_factor; @@ -4308,185 +4301,266 @@ fn apply_panel_bounds_change(sample: &mut PanelBoundsSample, change: PanelBounds } } -fn schedule_panel_bounds_settle( - window: &WebviewWindow, - store: &Arc, - generation: &Arc, - applied_max_height: &Arc>>, - settle_state: &Arc>, - change: PanelBoundsChange, -) { - let mut state = settle_state.lock(); - if !state.active { - return; +impl PanelBoundsPersistenceController { + fn new(store: Arc) -> Self { + Self { + store, + state: Mutex::new(PanelBoundsPersistenceState::default()), + persist_lock: Mutex::new(()), + } } - if state.latest.is_none() { - state.latest = panel_bounds_sample_from_window(window).ok(); + + fn attach(&self, window: &WebviewWindow, max_height: f64) -> u64 { + let latest = panel_bounds_sample_from_window(window).ok(); + let mut state = self.state.lock(); + state.session = state.session.wrapping_add(1); + state.latest = latest; + state.window = Some(window.clone()); + state.applied_max_height = Some(max_height); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + state.active = true; + state.session + } + + fn record_event(self: &Arc, session: u64, change: PanelBoundsChange) { + let should_spawn = { + let mut state = self.state.lock(); + if !state.active || state.session != session { + return; + } + if state.latest.is_none() { + state.latest = state + .window + .as_ref() + .and_then(|window| panel_bounds_sample_from_window(window).ok()); + } + Self::record_change(&mut state, session, change) + }; + if should_spawn { + self.spawn_worker(); + } } - if let Some(latest) = state.latest.as_mut() { + + fn record_change( + state: &mut PanelBoundsPersistenceState, + session: u64, + change: PanelBoundsChange, + ) -> bool { + if !state.active || state.session != session { + return false; + } + if let PanelBoundsChange::Snapshot(snapshot) = change { + state.latest = Some(snapshot); + return Self::mark_dirty(state); + } + let Some(latest) = state.latest.as_mut() else { + return false; + }; apply_panel_bounds_change(latest, change); + Self::mark_dirty(state) } - let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); - state.generation = scheduled_generation; - if state.worker_running { - return; + + fn mark_dirty(state: &mut PanelBoundsPersistenceState) -> bool { + state.generation = state.generation.wrapping_add(1); + state.dirty = true; + let should_spawn = !state.worker_running; + state.worker_running = true; + should_spawn } - state.worker_running = true; - drop(state); - let window = window.clone(); - let store = Arc::clone(store); - let generation = Arc::clone(generation); - let applied_max_height = Arc::clone(applied_max_height); - let settle_state = Arc::clone(settle_state); - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; - let (observed_generation, sample) = { - let mut state = settle_state.lock(); - if !state.active { - state.worker_running = false; - return; - } - if generation.load(Ordering::SeqCst) != state.generation { - state.worker_running = false; - return; - } - let Some(sample) = state.latest else { - state.worker_running = false; - return; - }; - (state.generation, sample) - }; + fn take_dirty_work(state: &mut PanelBoundsPersistenceState) -> Option { + let sample = state.latest.filter(|_| state.active && state.dirty)?; + state.dirty = false; + Some(PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + }) + } - let is_current = || { - let state = settle_state.lock(); - state.active - && state.generation == observed_generation - && generation.load(Ordering::SeqCst) == observed_generation - }; - if !is_current() { - continue; - } + fn work_is_current(state: &PanelBoundsPersistenceState, work: &PanelBoundsPersistWork) -> bool { + state.active && state.session == work.session && state.generation == work.generation + } - let bounds = panel_bounds_from_sample(sample); - if !is_current() { - continue; - } - let persisted = Arc::new(AtomicBool::new(false)); - let persist_generation = Arc::clone(&generation); - let persist_state = Arc::clone(&settle_state); - let persisted_result = Arc::clone(&persisted); - if let Err(err) = store.update_deferred(move |state| { - let settle = persist_state.lock(); - if settle.active - && settle.generation == observed_generation - && persist_generation.load(Ordering::SeqCst) == observed_generation - { - state.panel_bounds = Some(bounds); - persisted_result.store(true, Ordering::SeqCst); + fn restore_failed_work( + state: &mut PanelBoundsPersistenceState, + work: &PanelBoundsPersistWork, + ) -> bool { + if !Self::work_is_current(state, work) { + return false; + } + state.dirty = true; + true + } + + fn persist_sample(&self, sample: PanelBoundsSample) -> Result<()> { + let bounds = panel_bounds_from_sample(sample); + self.store + .update_deferred(move |data| { + data.panel_bounds = Some(bounds); + }) + .context("failed to capture settled panel bounds")?; + self.store + .flush() + .context("failed to flush settled panel bounds") + } + + fn persist_worker_work(&self, work: PanelBoundsPersistWork) -> Result { + let _persist_guard = self.persist_lock.lock(); + if !Self::work_is_current(&self.state.lock(), &work) { + return Ok(false); + } + self.persist_sample(work.sample)?; + Ok(true) + } + + fn spawn_worker(self: &Arc) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; + let (work, window) = { + let mut state = controller.state.lock(); + let Some(work) = Self::take_dirty_work(&mut state) else { + state.worker_running = false; + return; + }; + (work, state.window.clone()) + }; + + let persist_result = controller.persist_worker_work(work); + if let Err(err) = &persist_result { + log::warn!("failed to persist settled panel bounds: {err:#}"); } - }) { - log::warn!("failed to capture settled panel bounds: {err}"); - } else if persisted.load(Ordering::SeqCst) && is_current() { - if let Err(err) = store.flush() { - log::warn!("failed to flush settled panel bounds: {err}"); + + if let Some(window) = window { + let constraint_controller = Arc::clone(&controller); + let constraint_window = window.clone(); + if let Err(err) = window.app_handle().run_on_main_thread(move || { + if let Err(err) = constraint_controller.apply_monitor_constraints( + &constraint_window, + work.session, + work.generation, + ) { + log::warn!("failed to update settled panel monitor constraints: {err}"); + } + }) { + log::warn!("failed to schedule settled panel constraints: {err}"); + } } - } - let constraint_window = window.clone(); - let constraint_max_height = Arc::clone(&applied_max_height); - let constraint_generation = Arc::clone(&generation); - let constraint_state = Arc::clone(&settle_state); - if let Err(err) = window.app_handle().run_on_main_thread(move || { - let is_current = { - let state = constraint_state.lock(); - state.active - && state.generation == observed_generation - && constraint_generation.load(Ordering::SeqCst) == observed_generation - }; - if !is_current { + let mut state = controller.state.lock(); + if persist_result.is_err() && Self::restore_failed_work(&mut state, &work) { + state.worker_running = false; return; } - if let Err(err) = - apply_panel_monitor_constraints(&constraint_window, &constraint_max_height) - { - log::warn!("failed to update settled panel monitor constraints: {err}"); + if !state.active || !state.dirty { + state.worker_running = false; + return; } - }) { - log::warn!("failed to schedule settled panel constraints: {err}"); } + }); + } - let mut state = settle_state.lock(); - if state.generation == observed_generation - && generation.load(Ordering::SeqCst) == observed_generation - { - state.worker_running = false; - return; + fn apply_monitor_constraints( + &self, + window: &WebviewWindow, + session: u64, + generation: u64, + ) -> Result<()> { + let Some(max_height) = window + .current_monitor()? + .and_then(MonitorSpec::from_monitor) + .map(|monitor| panel_max_height(Some(monitor.logical_height))) + else { + return Ok(()); + }; + let max_height = { + let state = self.state.lock(); + if !state.active || state.session != session || state.generation != generation { + return Ok(()); } + changed_panel_max_height(state.applied_max_height, max_height) + }; + let Some(max_height) = max_height else { + return Ok(()); + }; + window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( + PANEL_WIDTH, + max_height, + ))))?; + let mut state = self.state.lock(); + if state.active && state.session == session && state.generation == generation { + state.applied_max_height = Some(max_height); } - }); -} + Ok(()) + } -fn apply_panel_monitor_constraints( - window: &WebviewWindow, - applied_max_height: &Mutex>, -) -> Result<()> { - let Some(max_height) = window - .current_monitor()? - .and_then(MonitorSpec::from_monitor) - .map(|monitor| panel_max_height(Some(monitor.logical_height))) - else { - return Ok(()); - }; - let mut applied = applied_max_height.lock(); - let Some(max_height) = changed_panel_max_height(*applied, max_height) else { - return Ok(()); - }; - window.set_max_size(Some(tauri::Size::Logical(tauri::LogicalSize::new( - PANEL_WIDTH, - max_height, - ))))?; - *applied = Some(max_height); - Ok(()) -} + fn flush_now(&self, window: &WebviewWindow) -> Result<()> { + let _persist_guard = self.persist_lock.lock(); + let generation_before_sample = self.state.lock().generation; + let sampled = panel_bounds_sample_from_window(window); + Self::flush_samples(&self.state, generation_before_sample, sampled, |sample| { + self.persist_sample(sample) + }) + } -fn defer_panel_bounds_from_window( - window: &WebviewWindow, - store: &Arc, - generation: &Arc, -) -> Result<()> { - let scale_factor = window.scale_factor().unwrap_or(1.0); - let position = window.outer_position()?.to_logical::(scale_factor); - let size = window.inner_size()?.to_logical::(scale_factor); - let bounds = PanelBounds { - x: position.x, - y: position.y, - height: size.height.max(PANEL_MIN_HEIGHT), - }; - let scheduled_generation = generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1); - store.update_deferred(move |state| { - state.panel_bounds = Some(bounds); - })?; + fn flush_samples( + state_mutex: &Mutex, + generation_before_sample: u64, + sampled: Result, + mut persist: impl FnMut(PanelBoundsSample) -> Result<()>, + ) -> Result<()> { + let mut work = { + let mut state = state_mutex.lock(); + let sampled = match sampled { + Ok(sample) => sample, + Err(error) => state.latest.ok_or(error)?, + }; + let sample = if state.generation != generation_before_sample { + state.latest.unwrap_or(sampled) + } else { + sampled + }; + state.latest = Some(sample); + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + PanelBoundsPersistWork { + session: state.session, + generation: state.generation, + sample, + } + }; - let store = Arc::clone(store); - let generation = Arc::clone(generation); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(Duration::from_millis(PANEL_BOUNDS_DEBOUNCE_MS)).await; - if generation.load(Ordering::SeqCst) != scheduled_generation { - return; - } - if let Err(err) = store.flush() { - log::warn!("failed to flush debounced panel bounds: {err}"); + loop { + if let Err(error) = persist(work.sample) { + Self::restore_failed_work(&mut state_mutex.lock(), &work); + return Err(error); + } + let Some(next) = Self::take_dirty_work(&mut state_mutex.lock()) else { + return Ok(()); + }; + work = next; } - }); + } - Ok(()) -} + fn deactivate(&self, session: u64) { + let mut state = self.state.lock(); + Self::deactivate_state(&mut state, session); + } -fn flush_deferred_panel_bounds(store: &Arc, generation: &Arc) -> Result<()> { - generation.fetch_add(1, Ordering::SeqCst); - store.flush() + fn deactivate_state(state: &mut PanelBoundsPersistenceState, session: u64) -> bool { + if state.session != session { + return false; + } + state.active = false; + state.window = None; + state.applied_max_height = None; + state.generation = state.generation.wrapping_add(1); + state.dirty = false; + true + } } fn defer_overlay_bounds_from_window( @@ -4607,7 +4681,8 @@ mod tests { take_targeted_panel_view_state, validate_selection_session, EditorFlushAcknowledge, EditorFlushCompletion, EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, FrontendHistoryFlushPhase, FrontendHistoryFlushReady, FrontendLifecycleAction, - LifecycleHandshakeInstall, MonitorData, Mutex, PanelBoundsChange, PanelBoundsSample, + LifecycleHandshakeInstall, MonitorData, Mutex, PanelBoundsChange, + PanelBoundsPersistenceController, PanelBoundsPersistenceState, PanelBoundsSample, PanelCloseRequestState, PanelCloseRequestedPayload, PanelLayerTab, PanelPropertyTab, PanelViewMode, PanelViewState, PanelViewTarget, PanelVisibilityEventEmitter, PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize, @@ -4712,7 +4787,7 @@ mod tests { } #[test] - fn settled_panel_bounds_preserve_each_geometry_scale_domain() { + fn panel_bounds_controller_preserves_scale_domains_and_coalesces_events() { let mut sample = PanelBoundsSample { position: PhysicalPosition::new(600, 300), position_scale_factor: 2.0, @@ -4733,6 +4808,243 @@ mod tests { assert_eq!(bounds.x, 300.0); assert_eq!(bounds.y, 150.0); assert_eq!(bounds.height, 1_000.0); + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 1, + active: true, + ..PanelBoundsPersistenceState::default() + }; + assert!(PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + assert!(!PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Resized(PhysicalSize::new(480, 1_200)), + )); + assert_eq!( + (state.generation, state.worker_running, state.dirty), + (2, true, true) + ); + let mut empty_state = PanelBoundsPersistenceState { + session: 1, + active: true, + ..PanelBoundsPersistenceState::default() + }; + assert!(PanelBoundsPersistenceController::record_change( + &mut empty_state, + 1, + PanelBoundsChange::Snapshot(sample), + )); + assert_eq!(empty_state.latest, Some(sample)); + } + + #[test] + fn panel_bounds_deactivate_ignores_a_stale_session_token() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + applied_max_height: Some(900.0), + session: 2, + generation: 7, + dirty: true, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + assert!(!PanelBoundsPersistenceController::deactivate_state( + &mut state, 1, + )); + assert!(state.active); + assert_eq!(state.session, 2); + assert_eq!(state.generation, 7); + assert!(state.dirty); + assert_eq!(state.latest, Some(sample)); + assert_eq!(state.applied_max_height, Some(900.0)); + } + + #[test] + fn panel_bounds_record_event_ignores_a_stale_session_token() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let mut state = PanelBoundsPersistenceState { + latest: Some(sample), + session: 2, + generation: 7, + active: true, + ..PanelBoundsPersistenceState::default() + }; + + assert!(!PanelBoundsPersistenceController::record_change( + &mut state, + 1, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + assert_eq!(state.latest, Some(sample)); + assert_eq!(state.generation, 7); + assert!(!state.dirty); + assert!(!state.worker_running); + } + + #[test] + fn panel_bounds_flush_keeps_event_after_sample_that_old_code_overwrote() { + let initial = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(initial), + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let generation_before_sample = state.lock().generation; + let sampled = state.lock().latest.unwrap(); + + assert!(PanelBoundsPersistenceController::record_change( + &mut state.lock(), + 4, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + let expected = state.lock().latest.unwrap(); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples( + &state, + generation_before_sample, + Ok(sampled), + |sample| { + persisted.push(sample); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(persisted, vec![expected]); + let state = state.lock(); + assert_eq!(state.latest, Some(expected)); + assert!(!state.dirty); + } + + #[test] + fn panel_bounds_flush_and_event_interleave_without_loss_or_duplicate_sample() { + let initial = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(initial), + session: 4, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(initial), |sample| { + persisted.push(sample); + if persisted.len() == 1 { + assert!(PanelBoundsPersistenceController::record_change( + &mut state.lock(), + 4, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + )); + } + Ok(()) + }) + .unwrap(); + + let mut expected_latest = initial; + apply_panel_bounds_change( + &mut expected_latest, + PanelBoundsChange::Moved(PhysicalPosition::new(800, 400)), + ); + assert_eq!(persisted, vec![initial, expected_latest]); + let state = state.lock(); + assert_eq!(state.latest, Some(expected_latest)); + assert!(!state.dirty); + } + + #[test] + fn panel_bounds_flush_uses_latest_when_window_read_fails() { + let latest = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(latest), + session: 5, + active: true, + ..PanelBoundsPersistenceState::default() + }); + let mut persisted = Vec::new(); + + PanelBoundsPersistenceController::flush_samples( + &state, + 0, + Err(anyhow::anyhow!("window bounds unavailable")), + |sample| { + persisted.push(sample); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(persisted, vec![latest]); + + let empty = Mutex::new(PanelBoundsPersistenceState::default()); + assert!(PanelBoundsPersistenceController::flush_samples( + &empty, + 0, + Err(anyhow::anyhow!("window bounds unavailable")), + |_| panic!("missing bounds must not be persisted"), + ) + .is_err()); + } + + #[test] + fn panel_bounds_flush_failure_restores_dirty_state() { + let sample = PanelBoundsSample { + position: PhysicalPosition::new(600, 300), + position_scale_factor: 2.0, + size: PhysicalSize::new(480, 1_000), + size_scale_factor: 2.0, + current_scale_factor: 2.0, + }; + let state = Mutex::new(PanelBoundsPersistenceState { + latest: Some(sample), + session: 6, + active: true, + ..PanelBoundsPersistenceState::default() + }); + + assert!( + PanelBoundsPersistenceController::flush_samples(&state, 0, Ok(sample), |_| Err( + anyhow::anyhow!("disk unavailable") + ),) + .is_err() + ); + assert!(state.lock().dirty); } #[test] diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index ebf0b796..e88b2335 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -20,6 +20,8 @@ pub(crate) const MAX_CUSTOM_TABS: usize = 30; const MAX_MUTATION_ID_BYTES: usize = 64; const MAX_GESTURE_ID_BYTES: usize = 64; +// 프론트 editorCoordinator.ts의 MAX_PENDING_GESTURE_IDS와 동일한 IPC 상한 +const MAX_GESTURE_IDS: usize = 32; const MAX_MODE_ID_BYTES: usize = 128; const MAX_MODES: usize = 64; const MAX_ITEMS_PER_MODE: usize = 512; @@ -44,6 +46,7 @@ pub(crate) type RequestFingerprint = [u8; 32]; struct FingerprintPayload<'a> { base_revision: u64, gesture_id: Option<&'a str>, + gesture_ids: &'a [String], changes: &'a crate::models::EditorPatchV1, } @@ -88,15 +91,27 @@ pub(crate) fn validate_request_envelope( )); } + if request.gesture_ids.len() > MAX_GESTURE_IDS + || request + .gesture_ids + .iter() + .chain(request.gesture_id.iter()) + .collect::>() + .len() + > MAX_GESTURE_IDS + { + return Err(EditorCommitError::too_many_gesture_ids(MAX_GESTURE_IDS)); + } + if request .gesture_id - .as_ref() - .is_some_and(|gesture_id| gesture_id.len() > MAX_GESTURE_ID_BYTES) + .iter() + .chain(request.gesture_ids.iter()) + .any(|gesture_id| { + gesture_id.len() > MAX_GESTURE_ID_BYTES || Uuid::parse_str(gesture_id).is_err() + }) { - return Err(EditorCommitError::validation( - "INVALID_GESTURE_ID", - "gestureId must be no longer than 64 bytes", - )); + return Err(EditorCommitError::invalid_gesture_id()); } Ok(()) @@ -199,12 +214,18 @@ pub(crate) fn validate_history_restore_metadata( pub(crate) fn request_fingerprint( request: &EditorCommitRequest, ) -> Result { - let value = serde_json::to_value(FingerprintPayload { + canonical_request_fingerprint(&FingerprintPayload { base_revision: request.base_revision, gesture_id: request.gesture_id.as_deref(), + gesture_ids: &request.gesture_ids, changes: &request.changes, }) - .map_err(|error| { +} + +pub(crate) fn canonical_request_fingerprint( + payload: &impl Serialize, +) -> Result { + let value = serde_json::to_value(payload).map_err(|error| { EditorCommitError::validation( "INVALID_REQUEST_PAYLOAD", format!("failed to serialize editor request: {error}"), @@ -1034,6 +1055,7 @@ mod tests { base_revision: 0, mutation_id: Uuid::new_v4().to_string(), gesture_id: None, + gesture_ids: Vec::new(), changes: EditorPatchV1 { keys: Some(keys), ..EditorPatchV1::default() @@ -1636,6 +1658,7 @@ mod tests { base_revision: 0, mutation_id: "not-a-uuid".to_string(), gesture_id: None, + gesture_ids: Vec::new(), changes: EditorPatchV1::default(), }; assert!(validate_request_envelope(&invalid).is_err()); @@ -1644,6 +1667,37 @@ mod tests { oversized_gesture.gesture_id = Some("가".repeat(22)); assert_eq!(oversized_gesture.gesture_id.as_ref().unwrap().len(), 66); assert!(validate_request_envelope(&oversized_gesture).is_err()); + + let mut oversized_merged_gesture = request(KeyMappings::new()); + oversized_merged_gesture.gesture_ids = vec!["가".repeat(22)]; + assert!(validate_request_envelope(&oversized_merged_gesture).is_err()); + + let mut malformed_gesture = request(KeyMappings::new()); + malformed_gesture.gesture_ids = vec!["not-a-uuid".to_string()]; + let malformed_error = validate_request_envelope(&malformed_gesture).unwrap_err(); + assert_eq!( + malformed_error.error_code, + crate::errors::EditorCommitErrorCode::InvalidGestureId + ); + assert!(!malformed_error.retryable); + + let mut too_many_gestures = request(KeyMappings::new()); + too_many_gestures.gesture_ids = (0..=MAX_GESTURE_IDS) + .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + let count_error = validate_request_envelope(&too_many_gestures).unwrap_err(); + assert_eq!( + count_error.error_code, + crate::errors::EditorCommitErrorCode::TooManyGestureIds + ); + assert!(!count_error.retryable); + + let mut representative_overflow = request(KeyMappings::new()); + representative_overflow.gesture_ids = (0..MAX_GESTURE_IDS) + .map(|index| Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + representative_overflow.gesture_id = Some(Uuid::from_u128(u128::MAX).to_string()); + assert!(validate_request_envelope(&representative_overflow).is_err()); } #[test] diff --git a/src-tauri/src/state/gesture.rs b/src-tauri/src/state/gesture.rs new file mode 100644 index 00000000..aeaf46bd --- /dev/null +++ b/src-tauri/src/state/gesture.rs @@ -0,0 +1,89 @@ +use std::collections::HashSet; + +use crate::{ + errors::EditorCommitError, + models::{EditorCommitRequest, GestureCommitRequest, PluginInstancesCommitRequest}, +}; + +use super::{ + editor::{request_payload_size, validate_request_envelope, validate_revision}, + plugin::validate_plugin_instances_request, +}; + +const MAX_GESTURE_PLUGINS: usize = 64; +const MAX_GESTURE_REQUEST_BYTES: usize = 16 * 1024 * 1024; + +pub(crate) fn validate_gesture_commit_request( + request: &GestureCommitRequest, +) -> Result { + validate_revision(request.editor_base_revision)?; + validate_revision(request.plugin_base_revision)?; + validate_revision(request.authority_generation)?; + if let Some(epoch) = request.observed_history_epoch { + validate_revision(epoch)?; + } + + let editor_envelope = EditorCommitRequest { + base_revision: request.editor_base_revision, + mutation_id: request.mutation_id.clone(), + gesture_id: Some(request.gesture_id.clone()), + gesture_ids: Vec::new(), + changes: request.editor_changes.clone().unwrap_or_default(), + }; + validate_request_envelope(&editor_envelope)?; + request_payload_size(&editor_envelope)?; + + if request.plugin_changes.is_empty() || request.plugin_changes.len() > MAX_GESTURE_PLUGINS { + return Err(EditorCommitError::validation( + "INVALID_GESTURE_PLUGIN_COUNT", + format!("gesture transaction must contain between 1 and {MAX_GESTURE_PLUGINS} plugins"), + )); + } + + let mut plugin_ids = HashSet::with_capacity(request.plugin_changes.len()); + for change in &request.plugin_changes { + if !plugin_ids.insert(change.plugin_id.as_str()) { + return Err(EditorCommitError::validation( + "DUPLICATE_GESTURE_PLUGIN", + format!( + "gesture transaction contains duplicate plugin '{}'", + change.plugin_id + ), + )); + } + let plugin_request = PluginInstancesCommitRequest { + plugin_id: change.plugin_id.clone(), + instances: change.instances.clone(), + mutation_id: request.mutation_id.clone(), + gesture_id: Some(request.gesture_id.clone()), + observed_history_epoch: request.observed_history_epoch, + expected_model_revision: Some(request.plugin_base_revision), + authority_generation: request.authority_generation, + }; + validate_plugin_instances_request(&plugin_request).map_err(|error| { + EditorCommitError::validation( + error.clone(), + format!( + "invalid plugin gesture change '{}': {error}", + change.plugin_id + ), + ) + })?; + } + + let size = serde_json::to_vec(request) + .map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize gesture request: {error}"), + ) + })? + .len(); + if size > MAX_GESTURE_REQUEST_BYTES { + return Err(EditorCommitError::validation( + "REQUEST_TOO_LARGE", + format!("gesture request exceeds the {MAX_GESTURE_REQUEST_BYTES} byte limit"), + )); + } + Ok(size) +} diff --git a/src-tauri/src/state/history.rs b/src-tauri/src/state/history.rs index ac3139fb..31ddadbd 100644 --- a/src-tauri/src/state/history.rs +++ b/src-tauri/src/state/history.rs @@ -33,6 +33,7 @@ pub(crate) enum HistoryScope { Counters, PresetFull, PluginElements, + Compound, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -55,10 +56,19 @@ pub(crate) struct HistoryEntry { pub(crate) scope: HistoryScope, pub(crate) before: HistorySnapshot, pub(crate) gesture_id: Option, + pub(crate) gesture_ids: Vec, size_bytes: usize, access_sequence: u64, } +impl HistoryEntry { + fn matches_any_gesture(&self, gesture_ids: &[String]) -> bool { + gesture_ids + .iter() + .any(|gesture_id| self.gesture_ids.contains(gesture_id)) + } +} + #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomTabsHistorySnapshot { @@ -258,6 +268,9 @@ pub(crate) enum HistorySnapshot { Counters(KeyCounters), PresetFull(Box), PluginElements(PluginElementsHistorySnapshot), + Compound { + snapshots: Vec, + }, } impl HistorySnapshot { @@ -269,23 +282,218 @@ impl HistorySnapshot { Self::Counters(_) => HistoryScope::Counters, Self::PresetFull(_) => HistoryScope::PresetFull, Self::PluginElements(_) => HistoryScope::PluginElements, + Self::Compound { .. } => HistoryScope::Compound, } } } +fn merged_editor_before( + first_fields: &[EditorField], + first_before: &EditorPatchV1, + changed_fields: Vec, + before: EditorPatchV1, +) -> HistorySnapshot { + let mut merged_fields = first_fields.to_vec(); + for field in changed_fields { + if !merged_fields.contains(&field) { + merged_fields.push(field); + } + } + let mut merged_before = before; + preserve_editor_before_values(&mut merged_before, first_before); + HistorySnapshot::Editor { + changed_fields: merged_fields, + before: merged_before, + } +} + +fn merge_editor_snapshot( + existing: &HistorySnapshot, + changed_fields: Vec, + before: EditorPatchV1, +) -> Result { + match existing { + HistorySnapshot::Editor { + changed_fields: first_fields, + before: first_before, + } => Ok(merged_editor_before( + first_fields, + first_before, + changed_fields, + before, + )), + HistorySnapshot::PluginElements(_) => Ok(HistorySnapshot::Compound { + snapshots: vec![ + existing.clone(), + HistorySnapshot::Editor { + changed_fields, + before, + }, + ], + }), + HistorySnapshot::Compound { snapshots } => { + validate_compound_snapshots(snapshots)?; + let mut merged = snapshots.clone(); + if let Some(index) = merged + .iter() + .position(|snapshot| matches!(snapshot, HistorySnapshot::Editor { .. })) + { + let HistorySnapshot::Editor { + changed_fields: first_fields, + before: first_before, + } = &merged[index] + else { + unreachable!(); + }; + merged[index] = + merged_editor_before(first_fields, first_before, changed_fields, before); + } else { + merged.push(HistorySnapshot::Editor { + changed_fields, + before, + }); + } + Ok(HistorySnapshot::Compound { snapshots: merged }) + } + _ => Err("gesture history cannot merge editor with this scope".to_string()), + } +} + +fn merge_plugin_elements_snapshot( + existing: &HistorySnapshot, + before: PluginElementsHistorySnapshot, +) -> Result { + match existing { + HistorySnapshot::Editor { .. } => Ok(HistorySnapshot::Compound { + snapshots: vec![existing.clone(), HistorySnapshot::PluginElements(before)], + }), + HistorySnapshot::PluginElements(first_before) => { + if first_before.plugin_id == before.plugin_id { + Ok(existing.clone()) + } else { + Ok(HistorySnapshot::Compound { + snapshots: vec![existing.clone(), HistorySnapshot::PluginElements(before)], + }) + } + } + HistorySnapshot::Compound { snapshots } => { + validate_compound_snapshots(snapshots)?; + if snapshots.iter().any(|snapshot| { + matches!( + snapshot, + HistorySnapshot::PluginElements(first_before) + if first_before.plugin_id == before.plugin_id + ) + }) { + return Ok(existing.clone()); + } + let mut merged = snapshots.clone(); + merged.push(HistorySnapshot::PluginElements(before)); + Ok(HistorySnapshot::Compound { snapshots: merged }) + } + _ => Err("gesture history cannot merge plugin elements with this scope".to_string()), + } +} + +fn validate_compound_snapshots(snapshots: &[HistorySnapshot]) -> Result<(), String> { + let mut has_editor = false; + let mut plugin_ids = HashSet::new(); + for snapshot in snapshots { + match snapshot { + HistorySnapshot::Editor { .. } if !has_editor => has_editor = true, + HistorySnapshot::PluginElements(before) + if plugin_ids.insert(before.plugin_id.as_str()) => {} + HistorySnapshot::Editor { .. } => { + return Err("compound history contains duplicate editor snapshots".to_string()) + } + HistorySnapshot::PluginElements(_) => { + return Err("compound history contains duplicate plugin snapshots".to_string()) + } + _ => return Err("compound history contains an unsupported snapshot".to_string()), + } + } + if snapshots.is_empty() { + return Err("compound history cannot be empty".to_string()); + } + Ok(()) +} + +fn normalize_gesture_ids(gesture_ids: Vec) -> Vec { + let mut normalized = Vec::with_capacity(gesture_ids.len()); + for gesture_id in gesture_ids { + if !normalized.contains(&gesture_id) { + normalized.push(gesture_id); + } + } + normalized +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct HistoryEntryPayload<'a> { scope: HistoryScope, before: &'a HistorySnapshot, - #[serde(skip_serializing_if = "Option::is_none")] - gesture_id: &'a Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + gesture_ids: &'a Vec, +} + +fn refresh_history_entry_size(entry: &mut HistoryEntry) { + if let Ok(payload) = serde_json::to_vec(&HistoryEntryPayload { + scope: entry.scope, + before: &entry.before, + gesture_ids: &entry.gesture_ids, + }) { + entry.size_bytes = payload.len(); + } +} + +fn remove_net_zero_editor_snapshot(entry: &mut HistoryEntry, canonical: &EditorDocumentV1) -> bool { + let editor_is_net_zero = |snapshot: &HistorySnapshot| { + matches!( + snapshot, + HistorySnapshot::Editor { + changed_fields, + before, + } if canonical.patch_for_fields(changed_fields) == *before + ) + }; + match &mut entry.before { + snapshot @ HistorySnapshot::Editor { .. } if editor_is_net_zero(snapshot) => return true, + HistorySnapshot::Compound { snapshots } => { + snapshots.retain(|snapshot| !editor_is_net_zero(snapshot)); + } + _ => {} + } + refresh_history_entry_size(entry); + matches!(&entry.before, HistorySnapshot::Compound { snapshots } if snapshots.is_empty()) +} + +fn remove_net_zero_plugin_snapshot( + entry: &mut HistoryEntry, + canonical: &PluginElementsHistorySnapshot, +) -> bool { + let plugin_is_net_zero = |snapshot: &HistorySnapshot| matches!(snapshot, HistorySnapshot::PluginElements(before) if before == canonical); + match &mut entry.before { + snapshot @ HistorySnapshot::PluginElements(_) if plugin_is_net_zero(snapshot) => { + return true + } + HistorySnapshot::Compound { snapshots } => { + snapshots.retain(|snapshot| !plugin_is_net_zero(snapshot)); + } + _ => {} + } + refresh_history_entry_size(entry); + matches!(&entry.before, HistorySnapshot::Compound { snapshots } if snapshots.is_empty()) } #[derive(Debug)] pub(crate) enum HistoryRecordPlan { Entry(Box), - Merge(Box), + Merge { + entry: Box, + target_access_sequence: u64, + remove_access_sequences: Vec, + }, Truncate, } @@ -363,51 +571,60 @@ impl HistoryService { self.history_revision } + #[cfg(test)] pub(crate) fn prepare_entry( &self, changed_fields: Vec, before: EditorPatchV1, gesture_id: Option, ) -> Result { - if let Some(top) = self.past.back().filter(|entry| { - self.future.is_empty() - && entry.scope == HistoryScope::Editor - && gesture_id.is_some() - && entry.gesture_id == gesture_id - }) { - let HistorySnapshot::Editor { - changed_fields: first_fields, - before: first_before, - } = &top.before - else { - return Err("editor history scope contains a non-editor snapshot".to_string()); - }; - let mut merged_fields = first_fields.clone(); - for field in changed_fields { - if !merged_fields.contains(&field) { - merged_fields.push(field); - } + self.prepare_entry_with_gesture_ids( + changed_fields, + before, + gesture_id.into_iter().collect(), + ) + } + + pub(crate) fn prepare_entry_with_gesture_ids( + &self, + changed_fields: Vec, + before: EditorPatchV1, + gesture_ids: Vec, + ) -> Result { + let gesture_ids = normalize_gesture_ids(gesture_ids); + if self.future.is_empty() && !gesture_ids.is_empty() { + if let Some(top) = self + .past + .back() + .filter(|entry| entry.matches_any_gesture(&gesture_ids)) + { + let merged = merge_editor_snapshot(&top.before, changed_fields, before)?; + let mut merged_gesture_ids = top + .gesture_ids + .iter() + .filter(|existing| !gesture_ids.contains(*existing)) + .cloned() + .collect::>(); + merged_gesture_ids.extend(gesture_ids); + return self.prepare_snapshot_with_gesture_ids( + merged.scope(), + merged, + normalize_gesture_ids(merged_gesture_ids), + Some(top.access_sequence), + Vec::new(), + ); } - let mut merged_before = before; - preserve_editor_before_values(&mut merged_before, first_before); - return self.prepare_snapshot( - HistoryScope::Editor, - HistorySnapshot::Editor { - changed_fields: merged_fields, - before: merged_before, - }, - gesture_id, - true, - ); } - self.prepare_snapshot( + + self.prepare_snapshot_with_gesture_ids( HistoryScope::Editor, HistorySnapshot::Editor { changed_fields, before, }, - gesture_id, - false, + gesture_ids, + None, + Vec::new(), ) } @@ -424,7 +641,7 @@ impl HistoryService { before, }, gesture_id, - false, + None, ) } @@ -436,7 +653,7 @@ impl HistoryService { HistoryScope::CustomTabs, HistorySnapshot::CustomTabs(before), None, - false, + None, ) } @@ -445,7 +662,7 @@ impl HistoryService { HistoryScope::Mode, HistorySnapshot::Mode(before), None, - false, + None, ) } @@ -457,7 +674,7 @@ impl HistoryService { HistoryScope::Counters, HistorySnapshot::Counters(before), None, - false, + None, ) } @@ -469,7 +686,7 @@ impl HistoryService { HistoryScope::PresetFull, HistorySnapshot::PresetFull(Box::new(before)), None, - false, + None, ) } @@ -478,31 +695,29 @@ impl HistoryService { before: PluginElementsHistorySnapshot, gesture_id: Option, ) -> Result { - if let Some(top) = self.past.back().filter(|entry| { + let gesture_ids = gesture_id.into_iter().collect::>(); + if let Some(target) = self.past.iter().rev().find(|entry| { self.future.is_empty() - && entry.scope == HistoryScope::PluginElements - && gesture_id.is_some() - && entry.gesture_id == gesture_id + && !gesture_ids.is_empty() + && entry.matches_any_gesture(&gesture_ids) }) { - let HistorySnapshot::PluginElements(first_before) = &top.before else { - return Err( - "plugin elements history scope contains a mismatched snapshot".to_string(), - ); - }; - if first_before.plugin_id == before.plugin_id { - return self.prepare_snapshot( - HistoryScope::PluginElements, - HistorySnapshot::PluginElements(first_before.clone()), - gesture_id, - true, - ); - } + let merged = merge_plugin_elements_snapshot(&target.before, before)?; + let mut merged_gesture_ids = target.gesture_ids.clone(); + merged_gesture_ids.extend(gesture_ids); + return self.prepare_snapshot_with_gesture_ids( + merged.scope(), + merged, + normalize_gesture_ids(merged_gesture_ids), + Some(target.access_sequence), + Vec::new(), + ); } - self.prepare_snapshot( + self.prepare_snapshot_with_gesture_ids( HistoryScope::PluginElements, HistorySnapshot::PluginElements(before), - gesture_id, - false, + gesture_ids, + None, + Vec::new(), ) } @@ -514,7 +729,37 @@ impl HistoryService { HistoryScope::PluginElements, HistorySnapshot::PluginElements(before), None, - false, + None, + ) + } + + pub(crate) fn prepare_opposite_compound_entry( + &self, + snapshots: Vec, + gesture_ids: Vec, + ) -> Result { + validate_compound_snapshots(&snapshots)?; + self.prepare_snapshot_with_gesture_ids( + HistoryScope::Compound, + HistorySnapshot::Compound { snapshots }, + gesture_ids, + None, + Vec::new(), + ) + } + + pub(crate) fn prepare_gesture_entry( + &self, + snapshots: Vec, + gesture_id: String, + ) -> Result { + validate_compound_snapshots(&snapshots)?; + self.prepare_snapshot_with_gesture_ids( + HistoryScope::Compound, + HistorySnapshot::Compound { snapshots }, + vec![gesture_id], + None, + Vec::new(), ) } @@ -523,12 +768,31 @@ impl HistoryService { scope: HistoryScope, before: HistorySnapshot, gesture_id: Option, - merge: bool, + merge_target: Option, + ) -> Result { + self.prepare_snapshot_with_gesture_ids( + scope, + before, + gesture_id.into_iter().collect(), + merge_target, + Vec::new(), + ) + } + + fn prepare_snapshot_with_gesture_ids( + &self, + scope: HistoryScope, + before: HistorySnapshot, + gesture_ids: Vec, + merge_target: Option, + remove_access_sequences: Vec, ) -> Result { + let gesture_ids = normalize_gesture_ids(gesture_ids); + let gesture_id = gesture_ids.last().cloned(); let size_bytes = serde_json::to_vec(&HistoryEntryPayload { scope, before: &before, - gesture_id: &gesture_id, + gesture_ids: &gesture_ids, }) .map_err(|error| format!("failed to serialize history entry: {error}"))? .len(); @@ -541,13 +805,17 @@ impl HistoryService { scope, before, gesture_id, + gesture_ids, size_bytes, access_sequence: 0, }); - Ok(if merge { - HistoryRecordPlan::Merge(entry) - } else { - HistoryRecordPlan::Entry(entry) + Ok(match merge_target { + Some(target_access_sequence) => HistoryRecordPlan::Merge { + entry, + target_access_sequence, + remove_access_sequences, + }, + None => HistoryRecordPlan::Entry(entry), }) } @@ -558,10 +826,20 @@ impl HistoryService { self.push_past(*entry); self.advance_revision(); } - HistoryRecordPlan::Merge(mut entry) => { - self.next_access_sequence = self.next_access_sequence.saturating_add(1); - entry.access_sequence = self.next_access_sequence; - if let Some(previous) = self.past.back_mut() { + HistoryRecordPlan::Merge { + mut entry, + target_access_sequence, + remove_access_sequences, + } => { + for access_sequence in remove_access_sequences { + self.remove_past_by_access_sequence(access_sequence); + } + if let Some(previous) = self + .past + .iter_mut() + .find(|entry| entry.access_sequence == target_access_sequence) + { + entry.access_sequence = previous.access_sequence; self.total_bytes = self.total_bytes.saturating_sub(previous.size_bytes); self.total_bytes = self.total_bytes.saturating_add(entry.size_bytes); *previous = *entry; @@ -586,23 +864,18 @@ impl HistoryService { pub(crate) fn apply_editor_record_plan( &mut self, - plan: HistoryRecordPlan, + mut plan: HistoryRecordPlan, canonical: &EditorDocumentV1, ) { - let net_zero_merge = matches!( - &plan, - HistoryRecordPlan::Merge(entry) - if matches!( - &entry.before, - HistorySnapshot::Editor { - changed_fields, - before, - } if canonical.patch_for_fields(changed_fields) == *before - ) - ); - if net_zero_merge { + let remove_entry = match &mut plan { + HistoryRecordPlan::Merge { entry, .. } => { + remove_net_zero_editor_snapshot(entry, canonical) + } + _ => false, + }; + if remove_entry { self.clear_future(); - self.pop_past(); + self.remove_merge_target(&plan); self.advance_revision(); self.enforce_budget(); return; @@ -612,20 +885,18 @@ impl HistoryService { pub(crate) fn apply_plugin_elements_record_plan( &mut self, - plan: HistoryRecordPlan, + mut plan: HistoryRecordPlan, canonical: &PluginElementsHistorySnapshot, ) { - let net_zero_merge = matches!( - &plan, - HistoryRecordPlan::Merge(entry) - if matches!( - &entry.before, - HistorySnapshot::PluginElements(before) if before == canonical - ) - ); - if net_zero_merge { + let remove_entry = match &mut plan { + HistoryRecordPlan::Merge { entry, .. } => { + remove_net_zero_plugin_snapshot(entry, canonical) + } + _ => false, + }; + if remove_entry { self.clear_future(); - self.pop_past(); + self.remove_merge_target(&plan); self.advance_revision(); self.enforce_budget(); return; @@ -730,6 +1001,34 @@ impl HistoryService { Some(entry) } + fn remove_merge_target(&mut self, plan: &HistoryRecordPlan) { + let HistoryRecordPlan::Merge { + target_access_sequence, + remove_access_sequences, + .. + } = plan + else { + return; + }; + for access_sequence in remove_access_sequences { + self.remove_past_by_access_sequence(*access_sequence); + } + self.remove_past_by_access_sequence(*target_access_sequence); + } + + fn remove_past_by_access_sequence(&mut self, target_access_sequence: u64) { + let Some(index) = self + .past + .iter() + .position(|entry| entry.access_sequence == target_access_sequence) + else { + return; + }; + if let Some(entry) = self.past.remove(index) { + self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes); + } + } + fn clear_past(&mut self) { self.past.clear(); self.recalculate_total_bytes(); @@ -1015,6 +1314,306 @@ mod tests { } } + fn plugin_snapshot(plugin_id: &str) -> PluginElementsHistorySnapshot { + PluginElementsHistorySnapshot { + plugin_id: plugin_id.to_string(), + instances: None, + } + } + + #[test] + fn shared_gesture_merges_editor_and_plugins_into_one_compound_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(editor); + for plugin_id in ["plugin-a", "plugin-b"] { + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id.clone())) + .unwrap(); + history.apply_record_plan(plugin); + } + + assert_eq!(history.past.len(), 1); + let entry = history.past.back().unwrap(); + assert_eq!(entry.scope, HistoryScope::Compound); + let HistorySnapshot::Compound { snapshots } = &entry.before else { + panic!("shared gesture must produce a compound snapshot"); + }; + assert_eq!(snapshots.len(), 3); + assert!(matches!(snapshots[0], HistorySnapshot::Editor { .. })); + assert!(matches!( + &snapshots[1], + HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-a" + )); + assert!(matches!( + &snapshots[2], + HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-b" + )); + assert_eq!(history.history_revision(), 1); + } + + #[test] + fn delayed_shared_gesture_merges_in_place_without_reordering_later_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(editor); + + let later = history + .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) + .unwrap(); + history.apply_record_plan(later); + let delayed = history + .prepare_plugin_elements_entry(plugin_snapshot("shared-plugin"), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(delayed); + + assert_eq!(history.past.len(), 2); + assert!(matches!( + history.past.front().map(|entry| &entry.before), + Some(HistorySnapshot::Compound { snapshots }) if snapshots.len() == 2 + )); + assert!(matches!( + history.past.back().map(|entry| &entry.before), + Some(HistorySnapshot::PluginElements(snapshot)) + if snapshot.plugin_id == "later-plugin" + )); + assert_eq!(history.history_revision(), 2); + } + + #[test] + fn repeated_editor_gesture_does_not_merge_across_an_intervening_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let first = history + .prepare_entry( + vec![EditorField::Keys], + patch("first"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(first); + let intervening = history + .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) + .unwrap(); + history.apply_record_plan(intervening); + + let repeated = history + .prepare_entry(vec![EditorField::Keys], patch("repeated"), Some(gesture_id)) + .unwrap(); + assert!(matches!(repeated, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(repeated); + + assert_eq!(history.past.len(), 3); + assert!(matches!( + history.past.back().map(|entry| &entry.before), + Some(HistorySnapshot::Editor { before, .. }) + if before.keys.as_ref().unwrap()["mode"] == ["repeated"] + )); + } + + #[test] + fn merged_editor_gesture_aliases_absorb_only_the_top_plugin_entry() { + let mut history = HistoryService::default(); + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + for (plugin_id, gesture_id) in [ + ("plugin-a", first_gesture.clone()), + ("plugin-b", second_gesture.clone()), + ] { + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(plugin); + } + + let editor = history + .prepare_entry_with_gesture_ids( + vec![EditorField::Keys], + patch("before"), + vec![first_gesture.clone(), second_gesture.clone()], + ) + .unwrap(); + history.apply_record_plan(editor); + + assert_eq!(history.past.len(), 2); + let first_entry = history.past.front().unwrap(); + assert!(matches!( + &first_entry.before, + HistorySnapshot::PluginElements(snapshot) if snapshot.plugin_id == "plugin-a" + )); + assert_eq!(first_entry.gesture_ids, vec![first_gesture.clone()]); + + let top = history.past.back().unwrap(); + assert_eq!(top.gesture_ids, vec![first_gesture, second_gesture]); + let HistorySnapshot::Compound { snapshots } = &top.before else { + panic!("top history entry must be compound"); + }; + assert!(matches!( + snapshots.as_slice(), + [ + HistorySnapshot::PluginElements(plugin), + HistorySnapshot::Editor { before, .. } + ] if plugin.plugin_id == "plugin-b" + && before.keys.as_ref().unwrap()["mode"] == ["before"] + )); + } + + #[test] + fn multi_alias_editor_stays_above_a_later_editor_entry() { + let mut history = HistoryService::default(); + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + for (plugin_id, gesture_id) in [ + ("plugin-a", first_gesture.clone()), + ("plugin-b", second_gesture.clone()), + ] { + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot(plugin_id), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(plugin); + } + + let intervening_gesture = uuid::Uuid::new_v4().to_string(); + let intervening_editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before-intervening-editor"), + Some(intervening_gesture.clone()), + ) + .unwrap(); + history.apply_record_plan(intervening_editor); + + let latest_editor = history + .prepare_entry_with_gesture_ids( + vec![EditorField::Keys], + patch("after-intervening-editor"), + vec![first_gesture.clone(), second_gesture.clone()], + ) + .unwrap(); + assert!(matches!(latest_editor, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(latest_editor); + + assert_eq!(history.past.len(), 4); + let mut undo_order = history.past.iter().rev(); + let latest = undo_order.next().unwrap(); + assert_eq!(latest.gesture_ids, vec![first_gesture, second_gesture]); + assert!(matches!( + &latest.before, + HistorySnapshot::Editor { before, .. } + if before.keys.as_ref().unwrap()["mode"] == ["after-intervening-editor"] + )); + + let intervening = undo_order.next().unwrap(); + assert_eq!(intervening.gesture_ids, vec![intervening_gesture]); + assert!(matches!( + &intervening.before, + HistorySnapshot::Editor { before, .. } + if before.keys.as_ref().unwrap()["mode"] == ["before-intervening-editor"] + )); + } + + #[test] + fn delayed_net_zero_merge_removes_only_its_original_entry() { + let mut history = HistoryService::default(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let original = plugin_snapshot("shared-plugin"); + let first = history + .prepare_plugin_elements_entry(original.clone(), Some(gesture_id.clone())) + .unwrap(); + history.apply_record_plan(first); + let later = history + .prepare_plugin_elements_entry(plugin_snapshot("later-plugin"), None) + .unwrap(); + history.apply_record_plan(later); + + let back_to_original = history + .prepare_plugin_elements_entry(original.clone(), Some(gesture_id)) + .unwrap(); + history.apply_plugin_elements_record_plan(back_to_original, &original); + + assert_eq!(history.past.len(), 1); + assert!(matches!( + history.past.back().map(|entry| &entry.before), + Some(HistorySnapshot::PluginElements(snapshot)) + if snapshot.plugin_id == "later-plugin" + )); + } + + #[test] + fn compound_entry_counts_as_one_budget_slot() { + let mut history = HistoryService::with_limits(8 * 1024 * 1024, 32 * 1024 * 1024, 1); + let gesture_id = uuid::Uuid::new_v4().to_string(); + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + history.apply_record_plan(editor); + let plugin = history + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id)) + .unwrap(); + history.apply_record_plan(plugin); + + assert_eq!(history.past.len(), 1); + assert!(history.status(false).can_undo); + } + + #[test] + fn compound_merge_honors_combined_entry_size_limit() { + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut probe = HistoryService::default(); + let editor = probe + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + probe.apply_record_plan(editor); + let compound = probe + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id.clone())) + .unwrap(); + let HistoryRecordPlan::Merge { + entry: compound, .. + } = compound + else { + panic!("shared gesture must prepare a compound merge"); + }; + let limit = compound.size_bytes - 1; + + let mut history = HistoryService::with_limits(limit, 32 * 1024 * 1024, 50); + let editor = history + .prepare_entry( + vec![EditorField::Keys], + patch("before"), + Some(gesture_id.clone()), + ) + .unwrap(); + assert!(matches!(editor, HistoryRecordPlan::Entry(_))); + history.apply_record_plan(editor); + let oversized = history + .prepare_plugin_elements_entry(plugin_snapshot("plugin-a"), Some(gesture_id)) + .unwrap(); + assert!(matches!(oversized, HistoryRecordPlan::Truncate)); + } + #[test] fn oversized_entry_truncates_both_stacks_atomically() { let mut history = HistoryService::with_limits(200, 1_000, 50); diff --git a/src-tauri/src/state/mod.rs b/src-tauri/src/state/mod.rs index bada1fa3..58bf0500 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -2,6 +2,7 @@ pub mod app_state; pub(crate) mod atomic_file; pub(crate) mod builtin_sounds; pub(crate) mod editor; +pub(crate) mod gesture; pub(crate) mod history; pub(crate) mod local_asset_path; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/state/plugin.rs b/src-tauri/src/state/plugin.rs index 0cbd401f..0afc5c18 100644 --- a/src-tauri/src/state/plugin.rs +++ b/src-tauri/src/state/plugin.rs @@ -357,6 +357,14 @@ pub(crate) fn validate_saved_plugin_instances( return Err(format!("INVALID_PLUGIN_INSTANCE_SIZE:{index}")); } } + if instance.z_index.is_some_and(|z_index| { + !z_index.is_finite() + || z_index.fract() != 0.0 + || z_index < f64::from(i32::MIN) + || z_index > f64::from(i32::MAX) + }) { + return Err(format!("INVALID_PLUGIN_INSTANCE_Z_INDEX:{index}")); + } if instance .tab_id .as_deref() @@ -664,10 +672,43 @@ mod tests { settings: None, measured_size: None, tab_id: Some("4key".to_string()), + hidden: false, + z_index: None, }; validate_saved_plugin_instances(&[valid]).unwrap(); } + #[test] + fn saved_instance_wire_defaults_legacy_visibility_and_z_index() { + let instance = serde_json::from_value::(serde_json::json!({ + "position": { "x": 1.0, "y": 2.0 }, + "tabId": "4key" + })) + .unwrap(); + + assert!(!instance.hidden); + assert_eq!(instance.z_index, None); + } + + #[test] + fn saved_instance_wire_rejects_invalid_z_indexes() { + for z_index in [1.5, f64::from(i32::MAX) + 1.0, f64::NAN] { + let instance = SavedPluginInstance { + position: PluginPoint { x: 1.0, y: 2.0 }, + settings: None, + measured_size: None, + tab_id: Some("4key".to_string()), + hidden: false, + z_index: Some(z_index), + }; + + assert_eq!( + validate_saved_plugin_instances(&[instance]).unwrap_err(), + "INVALID_PLUGIN_INSTANCE_Z_INDEX:0" + ); + } + } + #[test] fn plugin_instances_request_enforces_compact_size_limit() { let mut settings = BTreeMap::new(); @@ -680,6 +721,8 @@ mod tests { settings: Some(settings), measured_size: None, tab_id: Some("4key".to_string()), + hidden: false, + z_index: None, }; let request = PluginInstancesCommitRequest { plugin_id: "demo".to_string(), diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index faf26c54..11d088b9 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -11,9 +11,10 @@ use crate::errors::{EditorCommitError, EditorCommitErrorCode}; use crate::models::{ AppStoreData, CommittedEditorChange, CustomCssPatch, CustomJsPatch, EditorCommitOrigin, EditorCommitRequest, EditorCommitResult, EditorCommittedV1, EditorDocumentV1, EditorField, - EditorGetResult, EditorTransactionResult, FontType, HistoryStatus, KeyCounters, KeyPosition, - NoteSettingsPatch, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, - SavedPluginInstance, SettingsDiff, SettingsPatchInput, SettingsState, EDITOR_SCHEMA_VERSION, + EditorGetResult, EditorTransactionResult, FontType, GestureCommitRequest, GestureCommitResult, + HistoryStatus, KeyCounters, KeyPosition, NoteSettingsPatch, PluginInstancesCommitRequest, + PluginInstancesReconcileRequest, SavedPluginInstance, SettingsDiff, SettingsPatchInput, + SettingsState, EDITOR_SCHEMA_VERSION, }; use anyhow::{anyhow, Context, Result}; use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; @@ -24,11 +25,12 @@ use tauri::Runtime; use super::atomic_file::atomic_replace; use super::builtin_sounds::seed_builtin_sounds; use super::editor::{ - next_revision, repair_selected_mode, request_fingerprint, request_payload_size, - sync_key_counters, touched_pair, validate_document_transition, + canonical_request_fingerprint, next_revision, repair_selected_mode, request_fingerprint, + request_payload_size, sync_key_counters, touched_pair, validate_document_transition, validate_history_restore_metadata, validate_paired_update, validate_request_envelope, RequestFingerprint, MUTATION_ACK_CAPACITY, }; +use super::gesture::validate_gesture_commit_request; use super::history::{ CustomTabsHistorySnapshot, HistoryAdmissionGate, HistoryAdmissionLease, HistoryDirection, HistoryEntry, HistoryRecordPlan, HistoryScope, HistoryService, HistorySnapshot, @@ -85,6 +87,7 @@ struct VersionedStoreState { dirty: bool, accepting_writes: bool, mutation_acks: VecDeque, + gesture_mutation_acks: VecDeque, plugin_model_revision: u64, history: HistoryService, } @@ -115,6 +118,10 @@ pub(crate) enum HistoryAuxChange { plugin_id: String, revision: u64, }, + PluginElementsBatch { + plugin_ids: Vec, + revision: u64, + }, } #[derive(Debug)] @@ -131,6 +138,21 @@ pub(crate) struct AdmittedPluginInstancesCommit { _admission: HistoryAdmissionLease, } +#[derive(Debug)] +pub(crate) struct GestureCommitOutcome { + pub(crate) result: GestureCommitResult, + pub(crate) change: Option, + pub(crate) changed_plugin_ids: Vec, + pub(crate) history_status: Option, + pub(crate) replayed: bool, +} + +#[derive(Debug)] +pub(crate) struct AdmittedGestureCommit { + pub(crate) outcome: GestureCommitOutcome, + _admission: HistoryAdmissionLease, +} + #[derive(Debug)] pub(crate) struct AdmittedPluginStorageMutation { pub(crate) value: T, @@ -166,6 +188,13 @@ struct MutationAck { result: EditorCommitResult, } +#[derive(Clone)] +struct GestureMutationAck { + id: String, + fingerprint: RequestFingerprint, + result: GestureCommitResult, +} + struct PluginInstancesMutationInput { plugin_id: String, instances: Vec, @@ -175,6 +204,7 @@ struct PluginInstancesMutationInput { struct EditorPatchCommitOptions { mutation_id: String, gesture_id: Option, + gesture_ids: Vec, origin: EditorCommitOrigin, record_history: bool, apply_key_side_effects: bool, @@ -317,6 +347,7 @@ impl AppStore { dirty: false, accepting_writes: true, mutation_acks: VecDeque::with_capacity(MUTATION_ACK_CAPACITY), + gesture_mutation_acks: VecDeque::with_capacity(MUTATION_ACK_CAPACITY), plugin_model_revision: 0, history: HistoryService::default(), }), @@ -489,6 +520,7 @@ impl AppStore { return Err("HISTORY_SCOPE_MISMATCH".to_string()); } let target_gesture_id = target.gesture_id.clone(); + let target_gesture_ids = target.gesture_ids.clone(); let current_store = guard.data.clone(); let origin = match direction { @@ -515,6 +547,7 @@ impl AppStore { EditorPatchCommitOptions { mutation_id: operation_id.to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, record_history: false, apply_key_side_effects: false, @@ -643,6 +676,51 @@ impl AppStore { }), ) } + HistorySnapshot::Compound { snapshots } => { + let current = EditorDocumentV1::from_store(¤t_store); + let mut opposite_snapshots = Vec::with_capacity(snapshots.len()); + for snapshot in &snapshots { + match snapshot { + HistorySnapshot::Editor { changed_fields, .. } => { + opposite_snapshots.push(HistorySnapshot::Editor { + changed_fields: changed_fields.clone(), + before: current.patch_for_fields(changed_fields), + }); + } + HistorySnapshot::PluginElements(before) => { + opposite_snapshots.push(HistorySnapshot::PluginElements( + plugin_elements_snapshot(¤t_store, &before.plugin_id)?, + )); + } + _ => { + return Err( + "compound history contains an unsupported snapshot".to_string() + ) + } + } + } + let opposite = + require_history_entry(guard.history.prepare_opposite_compound_entry( + opposite_snapshots, + target_gesture_ids, + )?)?; + let (change, plugin_ids, plugin_model_revision) = self + .commit_compound_history_locked( + &mut guard, + &snapshots, + operation_id, + origin, + ) + .map_err(editor_history_error)?; + ( + opposite, + change, + Some(HistoryAuxChange::PluginElementsBatch { + plugin_ids, + revision: plugin_model_revision, + }), + ) + } }; guard @@ -764,6 +842,8 @@ impl AppStore { )); } + let gesture_id = request.history_gesture_id(); + let gesture_ids = request.echoed_gesture_ids(); let touched_fields = request.changes.included_fields(); let change = self.commit_editor_patch_locked( &mut guard, @@ -771,7 +851,8 @@ impl AppStore { &touched_fields, EditorPatchCommitOptions { mutation_id: request.mutation_id.clone(), - gesture_id: request.gesture_id, + gesture_id, + gesture_ids, origin: EditorCommitOrigin::StrictEditorCommit, record_history: true, apply_key_side_effects: true, @@ -795,25 +876,8 @@ impl AppStore { options: EditorPatchCommitOptions, ) -> std::result::Result { let current_store = guard.data.clone(); - let current = EditorDocumentV1::from_store(¤t_store); - let mut candidate = current.clone(); - candidate.apply_patch(changes); - - let mut scratch = current_store.clone(); - candidate.apply_to_store(&mut scratch); - crate::state::migration::canonicalize_gradient_pairs(&mut scratch); - candidate = EditorDocumentV1::from_store(&scratch); - - validate_paired_update( - ¤t, - &candidate, - touched_fields.contains(&EditorField::Keys), - touched_fields.contains(&EditorField::KeyPositions), - )?; - scratch.editor_revision = current_store.editor_revision; - validate_document_transition(¤t, &candidate, ¤t_store, &scratch)?; - - let changed_fields = current.changed_fields(&candidate); + let (current, candidate, mut scratch, changed_fields) = + prepare_editor_patch_transition(¤t_store, changes, touched_fields)?; if options.enforce_touched_fields && changed_fields .iter() @@ -843,10 +907,10 @@ impl AppStore { let history_plan = options .record_history .then(|| { - guard.history.prepare_entry( + guard.history.prepare_entry_with_gesture_ids( changed_fields.clone(), current.patch_for_fields(&changed_fields), - options.gesture_id.clone(), + options.gesture_ids.clone(), ) }) .transpose() @@ -875,6 +939,7 @@ impl AppStore { revision, mutation_id: options.mutation_id, gesture_id: options.gesture_id, + gesture_ids: options.gesture_ids, origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -895,6 +960,350 @@ impl AppStore { }) } + #[cfg(test)] + pub(crate) fn commit_gesture( + &self, + request: GestureCommitRequest, + ) -> std::result::Result { + let admission = self.admit_editor_mutation()?; + self.commit_gesture_with_admission(request, admission) + } + + pub(crate) fn commit_gesture_with_admission( + &self, + request: GestureCommitRequest, + admission: HistoryAdmissionLease, + ) -> std::result::Result { + validate_gesture_commit_request(&request)?; + let outcome = self.commit_gesture_admitted(request, &admission)?; + Ok(AdmittedGestureCommit { + outcome, + _admission: admission, + }) + } + + fn commit_gesture_admitted( + &self, + request: GestureCommitRequest, + admission: &HistoryAdmissionLease, + ) -> std::result::Result { + let fingerprint = canonical_request_fingerprint(&request)?; + let mut guard = self + .lock_for_update() + .map_err(|error| EditorCommitError::io(error.to_string()))?; + admission + .revalidate_for(&self.history_gate) + .map_err(|_| EditorCommitError::history_in_progress())?; + + if let Some(ack) = guard + .gesture_mutation_acks + .iter() + .find(|ack| ack.id == request.mutation_id) + { + if ack.fingerprint != fingerprint { + return Err(EditorCommitError::mutation_id_reused()); + } + return Ok(GestureCommitOutcome { + result: ack.result.clone(), + change: None, + changed_plugin_ids: Vec::new(), + history_status: None, + replayed: true, + }); + } + + validate_observed_history_epoch(&guard.history, request.observed_history_epoch)?; + if request.editor_base_revision != guard.data.editor_revision { + return Err(EditorCommitError::revision_conflict( + guard.data.editor_revision, + )); + } + if request.plugin_base_revision != guard.plugin_model_revision { + return Err(EditorCommitError::plugin_revision_conflict( + guard.plugin_model_revision, + )); + } + + let current_store = guard.data.clone(); + let (current_editor, candidate_editor, mut scratch, changed_fields) = + if let Some(changes) = request.editor_changes.as_ref() { + let touched_fields = changes.included_fields(); + prepare_editor_patch_transition(¤t_store, changes, &touched_fields)? + } else { + let current = EditorDocumentV1::from_store(¤t_store); + (current.clone(), current, current_store.clone(), Vec::new()) + }; + + let mut history_snapshots = Vec::with_capacity(request.plugin_changes.len() + 1); + if !changed_fields.is_empty() { + history_snapshots.push(HistorySnapshot::Editor { + changed_fields: changed_fields.clone(), + before: current_editor.patch_for_fields(&changed_fields), + }); + } + + let mut changed_plugin_ids = Vec::new(); + for plugin_change in &request.plugin_changes { + let current_snapshot = + plugin_elements_snapshot(¤t_store, &plugin_change.plugin_id).map_err( + |error| EditorCommitError::validation("INVALID_GESTURE_PLUGIN", error), + )?; + let canonical = PluginElementsHistorySnapshot { + plugin_id: plugin_change.plugin_id.clone(), + instances: (!plugin_change.instances.is_empty()) + .then_some(plugin_change.instances.clone()), + }; + if current_snapshot == canonical { + continue; + } + apply_plugin_elements_snapshot(&mut scratch, &canonical) + .map_err(|error| EditorCommitError::validation("INVALID_GESTURE_PLUGIN", error))?; + history_snapshots.push(HistorySnapshot::PluginElements(current_snapshot)); + changed_plugin_ids.push(plugin_change.plugin_id.clone()); + } + + let editor_revision = if changed_fields.is_empty() { + current_store.editor_revision + } else { + let revision = next_revision(current_store.editor_revision)?; + if changed_fields.contains(&EditorField::Keys) { + sync_key_counters(&mut scratch.key_counters, &candidate_editor.keys); + repair_selected_mode(&mut scratch); + } + scratch.editor_revision = revision; + revision + }; + let plugin_model_revision = if changed_plugin_ids.is_empty() { + guard.plugin_model_revision + } else { + next_plugin_model_revision(guard.plugin_model_revision).map_err(|error| { + EditorCommitError::validation("PLUGIN_MODEL_REVISION_OUT_OF_RANGE", error) + })? + }; + let result = GestureCommitResult { + editor_revision, + changed_fields: changed_fields.clone(), + plugin_model_revision, + changed_plugin_ids: changed_plugin_ids.clone(), + authority_generation: request.authority_generation, + }; + + if history_snapshots.is_empty() { + insert_gesture_mutation_ack( + &mut guard.gesture_mutation_acks, + request.mutation_id, + fingerprint, + result.clone(), + ); + return Ok(GestureCommitOutcome { + result, + change: None, + changed_plugin_ids: Vec::new(), + history_status: None, + replayed: false, + }); + } + + let history_plan = guard + .history + .prepare_gesture_entry(history_snapshots, request.gesture_id.clone()) + .map_err(|error| { + EditorCommitError::validation("HISTORY_SERIALIZATION_FAILED", error) + })?; + if matches!(history_plan, HistoryRecordPlan::Truncate) { + return Err(EditorCommitError::validation( + HISTORY_ENTRY_TOO_LARGE, + "gesture history entry exceeds the size limit", + )); + } + + let selected_key_type = scratch.selected_key_type.clone(); + let key_counters = scratch.key_counters.clone(); + self.commit_locked(&mut guard, scratch, ()) + .map_err(|error| EditorCommitError::io(error.to_string()))?; + guard.plugin_model_revision = plugin_model_revision; + guard.history.apply_record_plan(history_plan); + let history_status = Some(guard.history.issue_status(self.history_gate.is_closed())); + + let change = (!changed_fields.is_empty()).then(|| CommittedEditorChange { + result: EditorCommitResult { + revision: editor_revision, + changed_fields: changed_fields.clone(), + }, + event: Some(EditorCommittedV1 { + schema_version: EDITOR_SCHEMA_VERSION, + revision: editor_revision, + mutation_id: request.mutation_id.clone(), + gesture_id: Some(request.gesture_id.clone()), + gesture_ids: vec![request.gesture_id], + origin: EditorCommitOrigin::GestureCommit + .event_name() + .expect("gesture commits publish editor events"), + changed_fields: changed_fields.clone(), + patch: candidate_editor.patch_for_fields(&changed_fields), + }), + replayed: false, + document: candidate_editor, + selected_key_type, + key_counters, + history_status: None, + runtime_publication_generation: guard.revision, + }); + + insert_gesture_mutation_ack( + &mut guard.gesture_mutation_acks, + request.mutation_id, + fingerprint, + result.clone(), + ); + Ok(GestureCommitOutcome { + result, + change, + changed_plugin_ids, + history_status, + replayed: false, + }) + } + + fn commit_compound_history_locked( + &self, + guard: &mut VersionedStoreState, + snapshots: &[HistorySnapshot], + operation_id: &str, + origin: EditorCommitOrigin, + ) -> std::result::Result<(Option, Vec, u64), EditorCommitError> + { + let current_store = guard.data.clone(); + let mut editor_target = None; + let mut plugin_targets = Vec::new(); + let mut seen_plugin_ids = HashSet::new(); + + for snapshot in snapshots { + match snapshot { + HistorySnapshot::Editor { + changed_fields, + before, + } => { + if editor_target.is_some() { + return Err(EditorCommitError::validation( + "HISTORY_COMPOUND_INVALID", + "compound history contains duplicate editor snapshots", + )); + } + editor_target = Some((changed_fields, before)); + } + HistorySnapshot::PluginElements(target) => { + if !seen_plugin_ids.insert(target.plugin_id.as_str()) { + return Err(EditorCommitError::validation( + "HISTORY_COMPOUND_INVALID", + "compound history contains duplicate plugin snapshots", + )); + } + plugin_targets.push(target); + } + _ => { + return Err(EditorCommitError::validation( + "HISTORY_COMPOUND_INVALID", + "compound history contains an unsupported snapshot", + )) + } + } + } + + let (mut scratch, editor_restore) = if let Some((changed_fields, before)) = editor_target { + let (_, candidate, next_store, actual_fields) = + prepare_editor_patch_transition(¤t_store, before, changed_fields)?; + if actual_fields + .iter() + .any(|field| !changed_fields.contains(field)) + { + return Err(EditorCommitError::validation( + "HISTORY_RESTORE_CHANGED_UNDECLARED_FIELD", + "history restore changed an editor field outside its entry", + )); + } + (next_store, Some((candidate, actual_fields))) + } else { + (current_store.clone(), None) + }; + let mut plugin_ids = Vec::new(); + for target in plugin_targets { + let current = + plugin_elements_snapshot(¤t_store, &target.plugin_id).map_err(|error| { + EditorCommitError::validation("HISTORY_COMPOUND_INVALID", error) + })?; + if current == *target { + continue; + } + apply_plugin_elements_snapshot(&mut scratch, target).map_err(|error| { + EditorCommitError::validation("HISTORY_COMPOUND_INVALID", error) + })?; + plugin_ids.push(target.plugin_id.clone()); + } + + let editor_changed = editor_restore + .as_ref() + .is_some_and(|(_, changed_fields)| !changed_fields.is_empty()); + if !editor_changed && plugin_ids.is_empty() { + return Err(EditorCommitError::validation( + "HISTORY_TARGET_ALREADY_APPLIED", + "history target is already applied", + )); + } + + let editor_revision = if editor_changed { + let revision = next_revision(current_store.editor_revision)?; + scratch.editor_revision = revision; + revision + } else { + current_store.editor_revision + }; + let plugin_model_revision = if plugin_ids.is_empty() { + guard.plugin_model_revision + } else { + next_plugin_model_revision(guard.plugin_model_revision).map_err(|error| { + EditorCommitError::validation("PLUGIN_MODEL_REVISION_OUT_OF_RANGE", error) + })? + }; + let selected_key_type = scratch.selected_key_type.clone(); + let key_counters = scratch.key_counters.clone(); + + self.commit_locked(guard, scratch, ()) + .map_err(|error| EditorCommitError::io(error.to_string()))?; + guard.plugin_model_revision = plugin_model_revision; + + let change = editor_restore.and_then(|(candidate, changed_fields)| { + if changed_fields.is_empty() { + return None; + } + let event = origin.event_name().map(|origin| EditorCommittedV1 { + schema_version: EDITOR_SCHEMA_VERSION, + revision: editor_revision, + mutation_id: operation_id.to_string(), + gesture_id: None, + gesture_ids: Vec::new(), + origin, + changed_fields: changed_fields.clone(), + patch: candidate.patch_for_fields(&changed_fields), + }); + Some(CommittedEditorChange { + result: EditorCommitResult { + revision: editor_revision, + changed_fields, + }, + event, + replayed: false, + document: candidate, + selected_key_type, + key_counters, + history_status: None, + runtime_publication_generation: guard.revision, + }) + }); + + Ok((change, plugin_ids, plugin_model_revision)) + } + fn commit_custom_tabs_history_locked( &self, guard: &mut VersionedStoreState, @@ -949,6 +1358,7 @@ impl AppStore { revision, mutation_id: operation_id.to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -1020,6 +1430,7 @@ impl AppStore { revision, mutation_id: operation_id.to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -1294,6 +1705,7 @@ impl AppStore { revision, mutation_id: uuid::Uuid::new_v4().to_string(), gesture_id: None, + gesture_ids: Vec::new(), origin, changed_fields: changed_fields.clone(), patch: candidate.patch_for_fields(&changed_fields), @@ -2213,7 +2625,10 @@ fn ensure_generic_editor_unchanged(before: &AppStoreData, after: &AppStoreData) fn editor_error_outcome(code: EditorCommitErrorCode) -> &'static str { match code { EditorCommitErrorCode::RevisionConflict => "revision_conflict", + EditorCommitErrorCode::PluginRevisionConflict => "plugin_revision_conflict", EditorCommitErrorCode::ValidationFailed => "validation_failed", + EditorCommitErrorCode::TooManyGestureIds => "too_many_gesture_ids", + EditorCommitErrorCode::InvalidGestureId => "invalid_gesture_id", EditorCommitErrorCode::PairedUpdateRequired => "paired_update_required", EditorCommitErrorCode::MutationIdReused => "mutation_id_reused", EditorCommitErrorCode::HistoryInProgress => "history_in_progress", @@ -2222,10 +2637,45 @@ fn editor_error_outcome(code: EditorCommitErrorCode) -> &'static str { } } +fn prepare_editor_patch_transition( + current_store: &AppStoreData, + changes: &crate::models::EditorPatchV1, + touched_fields: &[EditorField], +) -> std::result::Result< + ( + EditorDocumentV1, + EditorDocumentV1, + AppStoreData, + Vec, + ), + EditorCommitError, +> { + let current = EditorDocumentV1::from_store(current_store); + let mut candidate = current.clone(); + candidate.apply_patch(changes); + + let mut scratch = current_store.clone(); + candidate.apply_to_store(&mut scratch); + crate::state::migration::canonicalize_gradient_pairs(&mut scratch); + candidate = EditorDocumentV1::from_store(&scratch); + + validate_paired_update( + ¤t, + &candidate, + touched_fields.contains(&EditorField::Keys), + touched_fields.contains(&EditorField::KeyPositions), + )?; + scratch.editor_revision = current_store.editor_revision; + validate_document_transition(¤t, &candidate, current_store, &scratch)?; + let changed_fields = current.changed_fields(&candidate); + + Ok((current, candidate, scratch, changed_fields)) +} + fn require_history_entry(plan: HistoryRecordPlan) -> Result { match plan { HistoryRecordPlan::Entry(entry) => Ok(*entry), - HistoryRecordPlan::Merge(_) => Err("HISTORY_INVALID_OPPOSITE_ENTRY".to_string()), + HistoryRecordPlan::Merge { .. } => Err("HISTORY_INVALID_OPPOSITE_ENTRY".to_string()), HistoryRecordPlan::Truncate => Err(HISTORY_ENTRY_TOO_LARGE.to_string()), } } @@ -2376,6 +2826,22 @@ fn insert_mutation_ack( }); } +fn insert_gesture_mutation_ack( + acks: &mut VecDeque, + id: String, + fingerprint: RequestFingerprint, + result: GestureCommitResult, +) { + if acks.len() == MUTATION_ACK_CAPACITY { + acks.pop_front(); + } + acks.push_back(GestureMutationAck { + id, + fingerprint, + result, + }); +} + struct AssetReferencePaths { keys: HashSet, complete: bool, @@ -3393,8 +3859,9 @@ mod tests { models::{ AppStoreData, CommittedEditorChange, CustomCss, CustomFont, CustomTab, EditorCommitOrigin, EditorCommitRequest, EditorDocumentV1, EditorField, EditorPatchV1, - FontSettings, FontType, GraphPosition, GraphStatType, GraphType, JsPlugin, KeyCounters, - KeyPosition, KnobPosition, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, + FontSettings, FontType, GestureCommitRequest, GesturePluginInstancesChange, + GraphPosition, GraphStatType, GraphType, JsPlugin, KeyCounters, KeyPosition, + KnobPosition, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, SavedPluginInstance, SettingsPatchInput, SoundLibraryEntry, SoundSource, StatPosition, StatType, TabCss, TabNoteSettings, @@ -3493,6 +3960,7 @@ mod tests { base_revision, mutation_id: mutation_id.into(), gesture_id: None, + gesture_ids: Vec::new(), changes, } } @@ -3512,6 +3980,8 @@ mod tests { settings: None, measured_size: None, tab_id: Some("4key".to_string()), + hidden: false, + z_index: None, } } @@ -3550,11 +4020,33 @@ mod tests { } } - fn legacy_editor_commit( + fn gesture_request( store: &AppStore, - fields: &[EditorField], - updater: impl FnOnce(&mut AppStoreData), - ) -> std::result::Result { + gesture_id: String, + editor_changes: EditorPatchV1, + plugin_id: &str, + instances: Vec, + ) -> GestureCommitRequest { + GestureCommitRequest { + gesture_id, + mutation_id: uuid::Uuid::new_v4().to_string(), + editor_base_revision: store.editor_get().revision, + plugin_base_revision: store.plugin_model_revision(), + observed_history_epoch: Some(store.state.read().history.history_epoch()), + authority_generation: 1, + editor_changes: Some(editor_changes), + plugin_changes: vec![GesturePluginInstancesChange { + plugin_id: plugin_id.to_string(), + instances, + }], + } + } + + fn legacy_editor_commit( + store: &AppStore, + fields: &[EditorField], + updater: impl FnOnce(&mut AppStoreData), + ) -> std::result::Result { store .commit_legacy_editor_transaction( EditorCommitOrigin::LegacyAdapter("test_adapter".to_string()), @@ -3568,194 +4060,1074 @@ mod tests { } #[test] - fn plugin_instances_commit_records_merges_and_skips_noop() { - let dir = test_directory("plugin-instances-commit-test"); + fn plugin_instances_commit_records_merges_and_skips_noop() { + let dir = test_directory("plugin-instances-commit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_revision = store.plugin_model_revision(); + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let first_request = plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(initial_revision), + ); + let first = store.commit_plugin_instances(first_request).unwrap(); + let first_revision = first.outcome.model_revision; + assert!(first.outcome.changed); + assert_eq!( + first + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(first); + + let second_request = plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(30.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(first_revision), + ); + let second = store.commit_plugin_instances(second_request).unwrap(); + let second_revision = second.outcome.model_revision; + assert_eq!( + second + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(second); + + let noop = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(30.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(second_revision), + )) + .unwrap(); + assert!(!noop.outcome.changed); + assert!(noop.outcome.history_status.is_none()); + drop(noop); + + let (instances, revision) = store.plugin_instances_get("demo-plugin").unwrap(); + assert_eq!(instances, vec![saved_plugin_instance(30.0)]); + assert_eq!(revision, second_revision); + assert_eq!(store.history_status().history_revision, 1); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(undo.status.can_redo); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(30.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn plugin_instance_visibility_and_z_index_survive_restart() { + let dir = test_directory("plugin-instance-layout-restart-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let mut expected = saved_plugin_instance(15.0); + expected.hidden = true; + expected.z_index = Some(-12.0); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![expected.clone()], + uuid::Uuid::new_v4().to_string(), + None, + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(committed); + store.flush_and_shutdown().unwrap(); + drop(store); + + let restored = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!( + restored.plugin_instances_get("demo-plugin").unwrap().0, + vec![expected] + ); + + restored.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn plugin_instances_history_budget_truncates_after_successful_commit() { + let dir = test_directory("plugin-instances-budget-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + let expected_revision = store.plugin_model_revision(); + + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(10.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(expected_revision), + )) + .unwrap(); + let status = committed.outcome.history_status.as_ref().unwrap(); + assert!(committed.outcome.changed); + assert!(!status.can_undo); + assert!(!status.can_redo); + assert_eq!( + status.truncated.as_ref().unwrap().reason, + HISTORY_ENTRY_TOO_LARGE + ); + drop(committed); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(10.0)] + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn plugin_instances_undo_redo_restore_backend_without_runtime_projection() { + let dir = test_directory("plugin-instances-history-restore-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let expected_revision = store.plugin_model_revision(); + let committed = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + None, + Some(expected_revision), + )) + .unwrap(); + drop(committed); + + let gate = store.history_gate(); + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + let Some(HistoryAuxChange::PluginElements { + plugin_id, + revision: undo_revision, + }) = undo.aux_change + else { + panic!("plugin undo must expose its projection payload"); + }; + assert_eq!(plugin_id, "demo-plugin"); + assert_eq!(undo_revision, store.plugin_model_revision()); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(undo.status.can_redo); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn mixed_editor_plugin_gesture_undoes_and_redoes_atomically() { + let dir = test_directory("compound-editor-plugin-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor).unwrap(); + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(42.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + let first_plugin_revision = plugin.outcome.model_revision; + assert_eq!( + plugin + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(plugin); + let second_plugin = store + .commit_plugin_instances(plugin_instances_request( + "second-plugin", + vec![saved_plugin_instance(84.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(first_plugin_revision), + )) + .unwrap(); + assert_eq!( + second_plugin + .outcome + .history_status + .as_ref() + .unwrap() + .history_revision, + 1 + ); + drop(second_plugin); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + let undo = store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + assert!(store + .plugin_instances_get("second-plugin") + .unwrap() + .0 + .is_empty()); + assert!(!undo.status.can_undo); + assert!(undo.status.can_redo); + assert!(undo.change.is_some()); + assert!(matches!( + undo.aux_change, + Some(HistoryAuxChange::PluginElementsBatch { ref plugin_ids, .. }) + if plugin_ids == &["demo-plugin".to_string(), "second-plugin".to_string()] + )); + drop(undo_barrier); + + let redo_id = uuid::Uuid::new_v4().to_string(); + let redo_barrier = gate.close(&redo_id).unwrap(); + let redo = store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + assert_eq!( + store.plugin_instances_get("second-plugin").unwrap().0, + vec![saved_plugin_instance(84.0)] + ); + assert!(redo.status.can_undo); + drop(redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn gesture_transactions_keep_rejoined_actions_atomic_and_ordered() { + let dir = test_directory("gesture-transaction-rejoined-actions-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let persist_count = store.writer.persist_count(); + + let first = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 10.0), + "demo-plugin", + vec![saved_plugin_instance(10.0)], + )) + .unwrap(); + assert_eq!(first.outcome.result.editor_revision, 1); + assert_eq!(first.outcome.result.plugin_model_revision, 1); + drop(first); + + let second = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 20.0), + "demo-plugin", + vec![saved_plugin_instance(20.0)], + )) + .unwrap(); + assert_eq!(second.outcome.result.editor_revision, 2); + assert_eq!(second.outcome.result.plugin_model_revision, 2); + assert_eq!(store.writer.persist_count(), persist_count + 2); + assert_eq!(store.history_status().history_revision, 2); + drop(second); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + for (expected_editor_x, expected_plugin_x) in + [(initial_x + 10.0, Some(10.0)), (initial_x, None)] + { + let operation_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_editor_x + ); + let plugin = store.plugin_instances_get("demo-plugin").unwrap().0; + assert_eq!( + plugin.first().map(|instance| instance.position.x), + expected_plugin_x + ); + } + + for (expected_editor_x, expected_plugin_x) in + [(initial_x + 10.0, 10.0), (initial_x + 20.0, 20.0)] + { + let operation_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_editor_x + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0[0] + .position + .x, + expected_plugin_x + ); + } + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn gesture_plugin_validation_failure_leaves_editor_and_store_unchanged() { + let dir = test_directory("gesture-transaction-validation-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let persist_count = store.writer.persist_count(); + let mut invalid = saved_plugin_instance(10.0); + invalid.position.x = f64::NAN; + + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![invalid], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + assert_eq!(store.writer.persist_count(), persist_count); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn gesture_persist_failure_does_not_publish_partial_state() { + let dir = test_directory("gesture-transaction-persist-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + store.writer.fail_next_persist(); + + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::IoError); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn gesture_oversized_history_rejects_the_whole_transaction() { + let dir = test_directory("gesture-transaction-history-size-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + store + .state + .write() + .history + .set_limits_for_test(1, 32 * 1024 * 1024, 50); + let before = store.snapshot(); + let before_plugin_revision = store.plugin_model_revision(); + let before_history_revision = store.history_status().history_revision; + let persist_count = store.writer.persist_count(); + + let error = store + .commit_gesture(gesture_request( + &store, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, before.key_positions["4key"][0].dx + 25.0), + "demo-plugin", + vec![saved_plugin_instance(25.0)], + )) + .unwrap_err(); + + assert_eq!(error.error_code, EditorCommitErrorCode::ValidationFailed); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some(HISTORY_ENTRY_TOO_LARGE) + ); + assert_eq!(store.snapshot(), before); + assert_eq!(store.plugin_model_revision(), before_plugin_revision); + assert_eq!( + store.history_status().history_revision, + before_history_revision + ); + assert_eq!(store.writer.persist_count(), persist_count); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn merged_editor_gesture_aliases_only_join_the_top_plugin_entry() { + let dir = test_directory("merged-editor-gesture-alias-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + + for (plugin_id, x, gesture_id) in [ + ("plugin-a", 41.0, first_gesture.clone()), + ("plugin-b", 82.0, second_gesture.clone()), + ] { + let plugin = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(x)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + } + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 60.0), + ); + editor.gesture_id = Some(second_gesture.clone()); + editor.gesture_ids = vec![first_gesture, second_gesture]; + store.commit_editor_document(editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let first_barrier = gate.close(&first_undo_id).unwrap(); + let first_undo = store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![saved_plugin_instance(41.0)] + ); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert!(first_undo.status.can_undo); + assert!(first_undo.status.can_redo); + drop(first_barrier); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let second_barrier = gate.close(&second_undo_id).unwrap(); + let second_undo = store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) + .unwrap(); + assert!(store.plugin_instances_get("plugin-a").unwrap().0.is_empty()); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert!(!second_undo.status.can_undo); + assert!(second_undo.status.can_redo); + drop(second_barrier); + + let first_redo_id = uuid::Uuid::new_v4().to_string(); + let first_redo_barrier = gate.close(&first_redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &first_redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![saved_plugin_instance(41.0)] + ); + assert!(store.plugin_instances_get("plugin-b").unwrap().0.is_empty()); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + drop(first_redo_barrier); + + let second_redo_id = uuid::Uuid::new_v4().to_string(); + let second_redo_barrier = gate.close(&second_redo_id).unwrap(); + let second_redo = store + .apply_history_operation(HistoryDirection::Redo, &second_redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + vec![saved_plugin_instance(41.0)] + ); + assert_eq!( + store.plugin_instances_get("plugin-b").unwrap().0, + vec![saved_plugin_instance(82.0)] + ); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 60.0 + ); + assert!(second_redo.status.can_undo); + assert!(!second_redo.status.can_redo); + drop(second_redo_barrier); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn multi_alias_editor_commit_stays_above_a_later_editor_commit() { + let dir = test_directory("multi-alias-editor-order-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let first_gesture = uuid::Uuid::new_v4().to_string(); + let second_gesture = uuid::Uuid::new_v4().to_string(); + + for (plugin_id, x, gesture_id) in [ + ("plugin-a", 41.0, first_gesture.clone()), + ("plugin-b", 82.0, second_gesture.clone()), + ] { + let plugin = store + .commit_plugin_instances(plugin_instances_request( + plugin_id, + vec![saved_plugin_instance(x)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + } + + let mut intervening_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 20.0), + ); + intervening_editor.gesture_id = Some(uuid::Uuid::new_v4().to_string()); + store.commit_editor_document(intervening_editor).unwrap(); + + let mut latest_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + latest_editor.gesture_id = Some(second_gesture.clone()); + latest_editor.gesture_ids = vec![first_gesture, second_gesture]; + store.commit_editor_document(latest_editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + for (expected_x, expected_a, expected_b) in [ + (initial_x + 20.0, Some(41.0), Some(82.0)), + (initial_x, Some(41.0), Some(82.0)), + (initial_x, Some(41.0), None), + (initial_x, None, None), + ] { + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_x + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + expected_a + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + assert_eq!( + store.plugin_instances_get("plugin-b").unwrap().0, + expected_b + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + drop(barrier); + } + assert!(!store.history_status().can_undo); + + for (expected_x, expected_a, expected_b) in [ + (initial_x, Some(41.0), None), + (initial_x, Some(41.0), Some(82.0)), + (initial_x + 20.0, Some(41.0), Some(82.0)), + (initial_x + 40.0, Some(41.0), Some(82.0)), + ] { + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + expected_x + ); + assert_eq!( + store.plugin_instances_get("plugin-a").unwrap().0, + expected_a + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + assert_eq!( + store.plugin_instances_get("plugin-b").unwrap().0, + expected_b + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default() + ); + drop(barrier); + } + assert!(!store.history_status().can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn merged_aliases_do_not_cross_an_intervening_same_plugin_edit() { + let dir = test_directory("intervening-same-plugin-alias-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let first_gesture = uuid::Uuid::new_v4().to_string(); + let intervening_gesture = uuid::Uuid::new_v4().to_string(); + let last_gesture = uuid::Uuid::new_v4().to_string(); + + for (x, gesture_id) in [ + (10.0, first_gesture.clone()), + (20.0, intervening_gesture), + (30.0, last_gesture.clone()), + ] { + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(x)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin); + } + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 60.0), + ); + editor.gesture_id = Some(last_gesture.clone()); + editor.gesture_ids = vec![first_gesture, last_gesture]; + store.commit_editor_document(editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + for expected_plugin_x in [Some(20.0), Some(10.0), None] { + let undo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .unwrap(); + let expected = expected_plugin_x + .map(|x| vec![saved_plugin_instance(x)]) + .unwrap_or_default(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + expected + ); + drop(barrier); + } + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(!store.history_status().can_undo); + + for expected_plugin_x in [10.0, 20.0, 30.0] { + let redo_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&redo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(expected_plugin_x)] + ); + drop(barrier); + } + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 60.0 + ); + assert!(!store.history_status().can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn delayed_plugin_commit_preserves_later_editor_undo_order() { + let dir = test_directory("delayed-compound-history-order-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); - let initial_revision = store.plugin_model_revision(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; let gesture_id = uuid::Uuid::new_v4().to_string(); - let first_request = plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(10.0)], + let mut mixed_editor = editor_request( + store.editor_get().revision, uuid::Uuid::new_v4().to_string(), - Some(gesture_id.clone()), - Some(initial_revision), - ); - let first = store.commit_plugin_instances(first_request).unwrap(); - let first_revision = first.outcome.model_revision; - assert!(first.outcome.changed); - assert_eq!( - first - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 + position_patch(&store, initial_x + 40.0), ); - drop(first); + mixed_editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(mixed_editor).unwrap(); - let second_request = plugin_instances_request( - "demo-plugin", - vec![saved_plugin_instance(30.0)], + let later_editor = editor_request( + store.editor_get().revision, uuid::Uuid::new_v4().to_string(), - Some(gesture_id), - Some(first_revision), - ); - let second = store.commit_plugin_instances(second_request).unwrap(); - let second_revision = second.outcome.model_revision; - assert_eq!( - second - .outcome - .history_status - .as_ref() - .unwrap() - .history_revision, - 1 + position_patch(&store, initial_x + 80.0), ); - drop(second); + store.commit_editor_document(later_editor).unwrap(); - let noop = store + let plugin = store .commit_plugin_instances(plugin_instances_request( "demo-plugin", - vec![saved_plugin_instance(30.0)], + vec![saved_plugin_instance(42.0)], uuid::Uuid::new_v4().to_string(), - None, - Some(second_revision), + Some(gesture_id), + Some(store.plugin_model_revision()), )) .unwrap(); - assert!(!noop.outcome.changed); - assert!(noop.outcome.history_status.is_none()); - drop(noop); - - let (instances, revision) = store.plugin_instances_get("demo-plugin").unwrap(); - assert_eq!(instances, vec![saved_plugin_instance(30.0)]); - assert_eq!(revision, second_revision); - assert_eq!(store.history_status().history_revision, 1); + drop(plugin); let gate = store.history_gate(); let counters = store.snapshot().key_counters; - let undo_id = uuid::Uuid::new_v4().to_string(); - let undo_barrier = gate.close(&undo_id).unwrap(); - let undo = store - .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let first_barrier = gate.close(&first_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert_eq!( + store.plugin_instances_get("demo-plugin").unwrap().0, + vec![saved_plugin_instance(42.0)] + ); + drop(first_barrier); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let second_barrier = gate.close(&second_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); assert!(store .plugin_instances_get("demo-plugin") .unwrap() .0 .is_empty()); - assert!(undo.status.can_redo); - drop(undo_barrier); - - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) - .unwrap(); - assert_eq!( - store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(30.0)] - ); - assert!(redo.status.can_undo); - drop(redo_barrier); + drop(second_barrier); store.flush_and_shutdown().unwrap(); let _ = std::fs::remove_dir_all(dir); } #[test] - fn plugin_instances_history_budget_truncates_after_successful_commit() { - let dir = test_directory("plugin-instances-budget-test"); + fn repeated_editor_gesture_keeps_an_intervening_plugin_undo_in_order() { + let dir = test_directory("repeated-editor-gesture-order-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); - store - .state - .write() - .history - .set_limits_for_test(1, 32 * 1024 * 1024, 50); - let expected_revision = store.plugin_model_revision(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); - let committed = store + let mut first_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 40.0), + ); + first_editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(first_editor).unwrap(); + let plugin = store .commit_plugin_instances(plugin_instances_request( "demo-plugin", - vec![saved_plugin_instance(10.0)], + vec![saved_plugin_instance(42.0)], uuid::Uuid::new_v4().to_string(), None, - Some(expected_revision), + Some(store.plugin_model_revision()), )) .unwrap(); - let status = committed.outcome.history_status.as_ref().unwrap(); - assert!(committed.outcome.changed); - assert!(!status.can_undo); - assert!(!status.can_redo); + drop(plugin); + let mut repeated_editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 80.0), + ); + repeated_editor.gesture_id = Some(gesture_id); + store.commit_editor_document(repeated_editor).unwrap(); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let first_undo_id = uuid::Uuid::new_v4().to_string(); + let first_barrier = gate.close(&first_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &first_undo_id, &counters, || {}) + .unwrap(); assert_eq!( - status.truncated.as_ref().unwrap().reason, - HISTORY_ENTRY_TOO_LARGE + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 ); - drop(committed); assert_eq!( store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(10.0)] + vec![saved_plugin_instance(42.0)] + ); + drop(first_barrier); + + let second_undo_id = uuid::Uuid::new_v4().to_string(); + let second_barrier = gate.close(&second_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &second_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + 40.0 + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + drop(second_barrier); + + let third_undo_id = uuid::Uuid::new_v4().to_string(); + let third_barrier = gate.close(&third_undo_id).unwrap(); + store + .apply_history_operation(HistoryDirection::Undo, &third_undo_id, &counters, || {}) + .unwrap(); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x ); + drop(third_barrier); store.flush_and_shutdown().unwrap(); let _ = std::fs::remove_dir_all(dir); } #[test] - fn plugin_instances_undo_redo_restore_backend_without_runtime_projection() { - let dir = test_directory("plugin-instances-history-restore-test"); + fn plugin_first_compound_undo_restores_both_scopes() { + let dir = test_directory("plugin-first-compound-history-test"); std::fs::create_dir_all(&dir).unwrap(); let store = AppStore::initialize_in_dir(&dir).unwrap(); - let expected_revision = store.plugin_model_revision(); - let committed = store + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let plugin = store .commit_plugin_instances(plugin_instances_request( "demo-plugin", - vec![saved_plugin_instance(42.0)], + vec![saved_plugin_instance(91.0)], uuid::Uuid::new_v4().to_string(), - None, - Some(expected_revision), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), )) .unwrap(); - drop(committed); + drop(plugin); + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 35.0), + ); + editor.gesture_id = Some(gesture_id); + store.commit_editor_document(editor).unwrap(); let gate = store.history_gate(); + let counters = store.snapshot().key_counters; let undo_id = uuid::Uuid::new_v4().to_string(); let undo_barrier = gate.close(&undo_id).unwrap(); - let counters = store.snapshot().key_counters; - let undo = store + store .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) .unwrap(); - let Some(HistoryAuxChange::PluginElements { - plugin_id, - revision: undo_revision, - }) = undo.aux_change - else { - panic!("plugin undo must expose its projection payload"); - }; - assert_eq!(plugin_id, "demo-plugin"); - assert_eq!(undo_revision, store.plugin_model_revision()); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); assert!(store .plugin_instances_get("demo-plugin") .unwrap() .0 .is_empty()); - assert!(undo.status.can_redo); drop(undo_barrier); - let redo_id = uuid::Uuid::new_v4().to_string(); - let redo_barrier = gate.close(&redo_id).unwrap(); - let redo = store - .apply_history_operation(HistoryDirection::Redo, &redo_id, &counters, || {}) + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn compound_history_persist_failure_changes_neither_scope() { + let dir = test_directory("compound-history-persist-failure-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let changed_x = initial_x + 55.0; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut editor = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, changed_x), + ); + editor.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor).unwrap(); + let plugin = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(66.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) .unwrap(); + drop(plugin); + + let gate = store.history_gate(); + let counters = store.snapshot().key_counters; + let undo_id = uuid::Uuid::new_v4().to_string(); + let undo_barrier = gate.close(&undo_id).unwrap(); + store.writer.fail_next_persist(); + assert!(store + .apply_history_operation(HistoryDirection::Undo, &undo_id, &counters, || {}) + .is_err()); + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + changed_x + ); assert_eq!( store.plugin_instances_get("demo-plugin").unwrap().0, - vec![saved_plugin_instance(42.0)] + vec![saved_plugin_instance(66.0)] ); - assert!(redo.status.can_undo); - drop(redo_barrier); + assert!(store.history_status().can_undo); + drop(undo_barrier); store.flush_and_shutdown().unwrap(); let _ = std::fs::remove_dir_all(dir); @@ -3860,6 +5232,72 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn mixed_net_zero_gesture_removes_compound_entry() { + let dir = test_directory("compound-net-zero-gesture-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_x = store.editor_get().document.key_positions["4key"][0].dx; + let gesture_id = uuid::Uuid::new_v4().to_string(); + + let mut editor_out = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x + 25.0), + ); + editor_out.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor_out).unwrap(); + let plugin_out = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + vec![saved_plugin_instance(25.0)], + uuid::Uuid::new_v4().to_string(), + Some(gesture_id.clone()), + Some(store.plugin_model_revision()), + )) + .unwrap(); + drop(plugin_out); + + let mut editor_back = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_x), + ); + editor_back.gesture_id = Some(gesture_id.clone()); + store.commit_editor_document(editor_back).unwrap(); + let plugin_back = store + .commit_plugin_instances(plugin_instances_request( + "demo-plugin", + Vec::new(), + uuid::Uuid::new_v4().to_string(), + Some(gesture_id), + Some(store.plugin_model_revision()), + )) + .unwrap(); + assert!( + !plugin_back + .outcome + .history_status + .as_ref() + .unwrap() + .can_undo + ); + drop(plugin_back); + + assert_eq!( + store.editor_get().document.key_positions["4key"][0].dx, + initial_x + ); + assert!(store + .plugin_instances_get("demo-plugin") + .unwrap() + .0 + .is_empty()); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn plugin_instances_reconcile_is_noop_when_canonical_tabs_are_valid() { let dir = test_directory("plugin-instances-reconcile-noop-test"); @@ -3903,6 +5341,8 @@ mod tests { let store = AppStore::initialize_in_dir(&dir).unwrap(); let mut default_tab = saved_plugin_instance(10.0); default_tab.tab_id = None; + default_tab.hidden = true; + default_tab.z_index = Some(17.0); let mut stale_tab = saved_plugin_instance(30.0); stale_tab.tab_id = Some("deleted-tab".to_string()); let before = vec![default_tab, stale_tab]; @@ -3928,6 +5368,8 @@ mod tests { drop(reconciled); let mut normalized = saved_plugin_instance(10.0); normalized.tab_id = Some("4key".to_string()); + normalized.hidden = true; + normalized.z_index = Some(17.0); assert_eq!( store.plugin_instances_get("demo-plugin").unwrap().0, vec![normalized.clone()] @@ -4240,6 +5682,7 @@ mod tests { let old_key = before.keys["4key"][0].clone(); let mut keys = before.keys.clone(); keys.get_mut("4key").unwrap()[0] = "StrictKey".to_string(); + let first_gesture_id = uuid::Uuid::new_v4().to_string(); let gesture_id = uuid::Uuid::new_v4().to_string(); let mut request = editor_request( 0, @@ -4250,6 +5693,11 @@ mod tests { }, ); request.gesture_id = Some(gesture_id.clone()); + request.gesture_ids = vec![ + first_gesture_id.clone(), + first_gesture_id.clone(), + gesture_id.clone(), + ]; let change = store.commit_editor_document(request).unwrap(); @@ -4260,6 +5708,10 @@ mod tests { change.event.as_ref().unwrap().gesture_id.as_deref(), Some(gesture_id.as_str()) ); + assert_eq!( + change.event.as_ref().unwrap().gesture_ids, + vec![first_gesture_id, gesture_id] + ); assert_eq!(store.writer.persist_count(), persist_count + 1); let snapshot = store.snapshot(); assert_eq!(snapshot.editor_revision, 1); diff --git a/src/renderer/__tests__/previewOverlaySession.test.ts b/src/renderer/__tests__/previewOverlaySession.test.ts index d3c440b3..160edb39 100644 --- a/src/renderer/__tests__/previewOverlaySession.test.ts +++ b/src/renderer/__tests__/previewOverlaySession.test.ts @@ -7,7 +7,6 @@ import { useStatItemStore } from '@stores/data/useStatItemStore'; import { composePreviewPositions, previewOverlay, - registerPreviewRevisionProbe, } from '@src/renderer/editor/runtime/previewOverlay'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; import { previewApi } from '@api/modules/previewApi'; @@ -175,54 +174,32 @@ describe('previewOverlay', () => { ).toBeUndefined(); }); - it('minRevision 게이트에 걸린 cancel은 revision 전진 후에만 처리', () => { - let currentRevision: number | null = 3; - registerPreviewRevisionProbe(() => currentRevision); - - previewOverlay.applyRemoteEnvelope(remoteEnvelope({ seq: 1 })); + it('병합 커밋은 두 창의 세션을 함께 끝내고 늦은 patch를 차단', () => { + previewOverlay.applyLocalPatch('main-session', '4key', [0], { width: 90 }); previewOverlay.applyRemoteEnvelope( remoteEnvelope({ - seq: 2, - kind: 'cancel', - targets: [], - patch: {}, - minRevision: 5, + sessionId: 'panel-session', + targets: [1], + patch: { width: 95 }, }), ); - // 아직 revision 미도달이라 프리뷰 유지 - expect(useKeyStore.getState().positions['4key'][0].backgroundColor).toBe( - '#ff0000', - ); - - currentRevision = 5; - previewOverlay.flushDeferredCancels(5); - expect( - useKeyStore.getState().positions['4key'][0].backgroundColor, - ).toBeUndefined(); - - registerPreviewRevisionProbe(null); - }); + previewOverlay.endSessions(['main-session', 'panel-session']); - it('revision이 이미 도달한 cancel은 즉시 처리', () => { - registerPreviewRevisionProbe(() => 10); + expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); + expect(useKeyStore.getState().positions['4key'][1].width).toBe(60); - previewOverlay.applyRemoteEnvelope(remoteEnvelope({ seq: 1 })); + previewOverlay.applyLocalPatch('main-session', '4key', [0], { width: 100 }); previewOverlay.applyRemoteEnvelope( remoteEnvelope({ + sessionId: 'panel-session', seq: 2, - kind: 'cancel', - targets: [], - patch: {}, - minRevision: 5, + targets: [1], + patch: { width: 105 }, }), ); - - expect( - useKeyStore.getState().positions['4key'][0].backgroundColor, - ).toBeUndefined(); - - registerPreviewRevisionProbe(null); + expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); + expect(useKeyStore.getState().positions['4key'][1].width).toBe(60); }); it('프리뷰 활성 중 canonical 편집이 들어와도 오버레이가 재합성됨', () => { @@ -325,7 +302,7 @@ describe('editGestureController', () => { expect(request.patch).toEqual({ backgroundColor: '#111111' }); }); - it('settleCommit 성공 시 세션 종료 + 수신측 정리 브로드캐스트', async () => { + it('settleCommit 성공 시 로컬 세션 종료 + 보조 cancel 브로드캐스트', async () => { editGestureController.preview('4key', [ { index: 0, patch: { width: 100 } }, ]); @@ -335,8 +312,10 @@ describe('editGestureController', () => { await flushPromises(); expect(editGestureController.hasActiveGesture()).toBe(false); - // 커밋 revision 미확정(null) 상태에서는 게이트 없이 보조 cancel 발송 - expect(previewApi.cancel).toHaveBeenCalledWith(sessionId, undefined); + // 배치 간격 커밋처럼 committed echo가 다른 gestureId로 향하면 + // 원격 창 세션이 잔존하므로 성공 경로에도 보조 cancel이 나가야 함 + expect(previewApi.cancel).toHaveBeenCalledTimes(1); + expect(previewApi.cancel).toHaveBeenCalledWith(sessionId); expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); }); diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index db64ac76..19ee82e2 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -68,10 +68,13 @@ describe('editor API compatibility adapters', () => { commitPatch.mockClear(); commitPatch.mockResolvedValue(structuredClone(document)); - await keysApi.updatePositions(positions, 'gesture-1'); + await keysApi.updatePositions( + positions, + '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', + ); expect(commitPatch).toHaveBeenCalledWith( { schemaVersion: 1, keyPositions: positions }, - { gestureId: 'gesture-1' }, + { gestureId: '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50' }, ); }); diff --git a/src/renderer/api/modules/gestureApi.ts b/src/renderer/api/modules/gestureApi.ts new file mode 100644 index 00000000..44739776 --- /dev/null +++ b/src/renderer/api/modules/gestureApi.ts @@ -0,0 +1,53 @@ +import { invoke } from '@tauri-apps/api/core'; + +import { assertSafeEditorRevision } from '@src/types/editor'; + +import type { SavedPluginInstanceWire } from './pluginInstancesApi'; +import type { EditorField, EditorPatchV1 } from '@src/types/editor'; + +export interface GesturePluginInstancesChange { + pluginId: string; + instances: SavedPluginInstanceWire[]; +} + +export interface GestureCommitRequest { + gestureId: string; + mutationId: string; + editorBaseRevision: number; + pluginBaseRevision: number; + observedHistoryEpoch?: number; + authorityGeneration: number; + editorChanges?: EditorPatchV1; + pluginChanges: GesturePluginInstancesChange[]; +} + +export interface GestureCommitResult { + editorRevision: number; + changedFields: EditorField[]; + pluginModelRevision: number; + changedPluginIds: string[]; + authorityGeneration: number; +} + +const assertGestureCommitResult = ( + result: GestureCommitResult, +): GestureCommitResult => { + assertSafeEditorRevision(result.editorRevision, 'editorRevision'); + assertSafeEditorRevision(result.pluginModelRevision, 'pluginModelRevision'); + assertSafeEditorRevision(result.authorityGeneration, 'authorityGeneration'); + if ( + !Array.isArray(result.changedFields) || + !Array.isArray(result.changedPluginIds) || + result.changedPluginIds.some((pluginId) => typeof pluginId !== 'string') + ) { + throw new Error('commit_gesture returned an invalid result'); + } + return result; +}; + +export const gestureApi = { + commit: async (request: GestureCommitRequest): Promise => + assertGestureCommitResult( + await invoke('commit_gesture', { request }), + ), +}; diff --git a/src/renderer/api/modules/keysApi.ts b/src/renderer/api/modules/keysApi.ts index 74662978..ba09fd78 100644 --- a/src/renderer/api/modules/keysApi.ts +++ b/src/renderer/api/modules/keysApi.ts @@ -3,6 +3,7 @@ import { subscribe } from './shared'; import { rawKeyEventBus } from '@utils/core/rawKeyEventBus'; import { enqueueEditorCompatibilityWrite } from '@src/renderer/editor/runtime/editorCompatibilityQueue'; import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { runLegacyEditorMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; import type { KeyCounterUpdate, @@ -24,6 +25,24 @@ import type { KeyCounters, } from '@src/types/key/keys'; +// 백엔드 gestureId UUID 강제와 동형 (canonical hyphenated + 32-hex simple) +const GESTURE_ID_PATTERN = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})$/i; + +// 플러그인 표면(dmn.keys.*)이 임의 문자열을 넘길 수 있음 - 비 UUID가 드레인에 +// 섞이면 같은 커밋의 사용자 편집까지 INVALID_GESTURE_ID로 통째 실패하므로 +// 유입 지점에서 걸러내고 커밋은 진행 +const sanitizeGestureId = (gestureId?: string): string | undefined => { + if (!gestureId || GESTURE_ID_PATTERN.test(gestureId)) return gestureId; + console.warn('[keysApi] Dropped non-UUID gestureId:', gestureId); + return undefined; +}; + +const gestureMeta = (gestureId?: string): { gestureId: string } | undefined => { + const safeId = sanitizeGestureId(gestureId); + return safeId ? { gestureId: safeId } : undefined; +}; + export const keysApi = { get: () => invoke('keys_get'), getCounters: () => invoke('keys_get_counters'), @@ -45,7 +64,7 @@ export const keysApi = { keys: mappings, keyPositions: positions, }, - gestureId ? { gestureId } : undefined, + gestureMeta(gestureId), ), () => ({ keys: structuredClone(mappings), @@ -61,15 +80,20 @@ export const keysApi = { schemaVersion: 1, keyPositions: positions, }, - gestureId ? { gestureId } : undefined, + gestureMeta(gestureId), ), () => structuredClone(positions), ), setMode: (mode: string) => invoke('keys_set_mode', { mode }), - resetAll: () => invoke('keys_reset_all'), + resetAll: () => + runLegacyEditorMutation(() => + invoke('keys_reset_all'), + ), resetMode: (mode: string) => - invoke('keys_reset_mode', { mode }), + runLegacyEditorMutation(() => + invoke('keys_reset_mode', { mode }), + ), setCounters: (counters: KeyCounters) => invoke('keys_set_counters', { counters }), resetCounters: () => invoke('keys_reset_counters'), @@ -117,9 +141,13 @@ export const keysApi = { customTabs: { list: () => invoke('custom_tabs_list'), create: (name: string) => - invoke('custom_tabs_create', { name }), + runLegacyEditorMutation(() => + invoke('custom_tabs_create', { name }), + ), delete: (id: string) => - invoke('custom_tabs_delete', { id }), + runLegacyEditorMutation(() => + invoke('custom_tabs_delete', { id }), + ), select: (id: string) => invoke('custom_tabs_select', { id }), restore: (customTabs: CustomTab[], selectedKeyType: string) => diff --git a/src/renderer/api/modules/pluginInstancesApi.ts b/src/renderer/api/modules/pluginInstancesApi.ts index 0e8c8413..556abaaa 100644 --- a/src/renderer/api/modules/pluginInstancesApi.ts +++ b/src/renderer/api/modules/pluginInstancesApi.ts @@ -8,6 +8,8 @@ export interface SavedPluginInstanceWire { settings?: Record; measuredSize?: { width: number; height: number }; tabId?: string; + hidden?: boolean; + zIndex?: number; } export interface PluginInstancesCommitRequest { diff --git a/src/renderer/api/modules/presetsApi.ts b/src/renderer/api/modules/presetsApi.ts index c774e549..f10ad537 100644 --- a/src/renderer/api/modules/presetsApi.ts +++ b/src/renderer/api/modules/presetsApi.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; +import { runLegacyEditorMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; import type { PresetOperationResult, @@ -8,9 +9,13 @@ import type { export const presetsApi = { save: () => invoke('preset_save'), - load: () => invoke('preset_load'), + load: () => + runLegacyEditorMutation(() => invoke('preset_load')), saveTab: () => invoke('preset_save_tab'), - loadTab: () => invoke('preset_load_tab'), + loadTab: () => + runLegacyEditorMutation(() => + invoke('preset_load_tab'), + ), onSnapshot: (listener: (snapshot: PresetSnapshot) => void) => subscribe('preset:snapshot', listener), }; diff --git a/src/renderer/api/modules/previewApi.ts b/src/renderer/api/modules/previewApi.ts index dd3f6c5f..a5211d5f 100644 --- a/src/renderer/api/modules/previewApi.ts +++ b/src/renderer/api/modules/previewApi.ts @@ -26,6 +26,6 @@ export const previewApi = { publish: (request: PreviewPublishRequest) => invoke('editor_preview_publish', { request }), - cancel: (sessionId: string, minRevision?: number) => - invoke('editor_preview_cancel', { sessionId, minRevision }), + cancel: (sessionId: string) => + invoke('editor_preview_cancel', { sessionId }), }; diff --git a/src/renderer/api/modules/resourceApi.ts b/src/renderer/api/modules/resourceApi.ts index 7514ae3b..b239ff0f 100644 --- a/src/renderer/api/modules/resourceApi.ts +++ b/src/renderer/api/modules/resourceApi.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; +import { runLegacyEditorMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; export const fontApi = { load: () => @@ -8,7 +9,11 @@ export const fontApi = { export const imageApi = { load: () => - invoke('image_load'), + runLegacyEditorMutation( + () => + invoke('image_load'), + { syncAfter: false }, + ), }; export const soundApi = { @@ -22,9 +27,14 @@ export const soundApi = { displayName, }), remove: (soundPath: string) => - invoke('sound_delete', { - soundPath, - }), + runLegacyEditorMutation(() => + invoke( + 'sound_delete', + { + soundPath, + }, + ), + ), setHidden: (soundPath: string, hidden: boolean) => invoke( 'sound_set_hidden', @@ -132,14 +142,18 @@ export const counterAnimationApi = { update: ( request: import('@src/types/plugin/api').CounterAnimationUpdateRequest, ) => - invoke( - 'counter_animation_update', - { request }, + runLegacyEditorMutation(() => + invoke( + 'counter_animation_update', + { request }, + ), ), remove: (id: string) => - invoke( - 'counter_animation_delete', - { id }, + runLegacyEditorMutation(() => + invoke( + 'counter_animation_delete', + { id }, + ), ), onChanged: ( listener: ( diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 74cc3933..d113f7ce 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -94,6 +94,15 @@ import { getPreviewOverlayVersion, subscribePreviewOverlay, } from '@src/renderer/editor/runtime/previewOverlay'; +import { + beginPluginInstancesEditSession, + endPluginInstancesEditSession, + rotatePluginInstancesEditSession, +} from '@plugins/runtime/displayElement/instancesCommitQueue'; +import { + beginMixedGestureTransaction, + cancelUncommittedMixedGestureTransaction, +} from '@plugins/runtime/displayElement/gestureTransaction'; type ToolbarAddRequest = { id: number; @@ -319,6 +328,64 @@ const Grid = ({ const pluginElements = usePluginDisplayElementStore( (state) => state.elements, ); + const selectedDragGestureIdRef = useRef(null); + + const rotatePluginElementSessions = (fullIds: string[]) => { + const selectedPluginIds = new Set(fullIds); + const pluginIds = new Set( + usePluginDisplayElementStore + .getState() + .elements.filter((element) => selectedPluginIds.has(element.fullId)) + .map((element) => element.pluginId), + ); + pluginIds.forEach((pluginId) => { + rotatePluginInstancesEditSession(pluginId); + }); + }; + + const beginSelectedPluginInstancesDrag = () => { + const gestureId = crypto.randomUUID(); + selectedDragGestureIdRef.current = gestureId; + const selectedPluginElementIds = new Set( + useGridSelectionStore + .getState() + .selectedElements.filter((element) => element.type === 'plugin') + .map((element) => element.id), + ); + const tokens = new Map(); + usePluginDisplayElementStore + .getState() + .elements.filter((element) => + selectedPluginElementIds.has(element.fullId), + ) + .forEach((element) => { + if (!tokens.has(element.pluginId)) { + tokens.set( + element.pluginId, + beginPluginInstancesEditSession(element.pluginId, gestureId), + ); + } + }); + if ( + tokens.size > 0 && + useGridSelectionStore + .getState() + .selectedElements.some((element) => element.type !== 'plugin') + ) { + beginMixedGestureTransaction(gestureId, [...tokens.keys()]); + } + return () => { + tokens.forEach((token, pluginId) => { + endPluginInstancesEditSession(pluginId, token); + }); + // 종료 경로가 혼합 커밋을 타지 않은 경우 staged 잔존으로 barrier가 + // 영구 대기하지 않도록 미커밋 staged만 정산 + cancelUncommittedMixedGestureTransaction(gestureId); + if (selectedDragGestureIdRef.current === gestureId) { + selectedDragGestureIdRef.current = null; + } + }; + }; // 내장 통계 요소(Stat Items) 위치 정보 const canonicalStatPositions = useStatItemStore((state) => state.positions); @@ -355,6 +422,11 @@ const Grid = ({ keyMappings, positions, }); + const commitSelectedElementsDrag = () => { + syncSelectedElementsToOverlay( + selectedDragGestureIdRef.current ?? undefined, + ); + }; // 마퀴 선택 훅 사용 const { isMarqueeSelecting: _isMarqueeSelecting, startMarqueeSelection } = @@ -398,6 +470,7 @@ const Grid = ({ } else if (selected.type === 'stat') { await moveStatForward(selected.index); } else if (selected.type === 'plugin') { + rotatePluginElementSessions([selected.id]); usePluginDisplayElementStore.getState().bringForward(selected.id); } else if (selected.type === 'graph') { await moveGraphForward(selected.index); @@ -415,6 +488,7 @@ const Grid = ({ } else if (selected.type === 'stat') { await moveStatBackward(selected.index); } else if (selected.type === 'plugin') { + rotatePluginElementSessions([selected.id]); usePluginDisplayElementStore.getState().sendBackward(selected.id); } else if (selected.type === 'graph') { await moveGraphBackward(selected.index); @@ -605,6 +679,12 @@ const Grid = ({ const moveSelectedToFront = async () => { if (selectedElements.length === 0) return; + rotatePluginElementSessions( + selectedElements + .filter((element) => element.type === 'plugin') + .map((element) => element.id), + ); + for (const el of selectedElements) { if (el.type === 'key' && el.index !== undefined) { if (typeof onMoveToFront === 'function') { @@ -627,6 +707,12 @@ const Grid = ({ const moveSelectedToBack = async () => { if (selectedElements.length === 0) return; + rotatePluginElementSessions( + selectedElements + .filter((element) => element.type === 'plugin') + .map((element) => element.id), + ); + for (const el of selectedElements) { if (el.type === 'key' && el.index !== undefined) { if (typeof onMoveToBack === 'function') { @@ -1036,9 +1122,10 @@ const Grid = ({ )} selectedElements={selectedElements} onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, false, false) + moveSelectedElements(deltaX, deltaY, undefined, false) } - onMultiDragEnd={syncSelectedElementsToOverlay} + onMultiDragStart={beginSelectedPluginInstancesDrag} + onMultiDragEnd={commitSelectedElementsDrag} activeTool={activeTool} onEraserClick={() => { const globalKey = keyMappings[selectedKeyType]?.[index] || ''; @@ -1124,9 +1211,10 @@ const Grid = ({ )} selectedElements={selectedElements} onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, false, false) + moveSelectedElements(deltaX, deltaY, undefined, false) } - onMultiDragEnd={syncSelectedElementsToOverlay} + onMultiDragStart={beginSelectedPluginInstancesDrag} + onMultiDragEnd={commitSelectedElementsDrag} activeTool={activeTool} onEraserClick={() => { const displayName = getStatTypeLabel(position.statType); @@ -1207,9 +1295,10 @@ const Grid = ({ )} selectedElements={selectedElements} onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, false, false) + moveSelectedElements(deltaX, deltaY, undefined, false) } - onMultiDragEnd={syncSelectedElementsToOverlay} + onMultiDragStart={beginSelectedPluginInstancesDrag} + onMultiDragEnd={commitSelectedElementsDrag} activeTool={activeTool} onEraserClick={() => { const displayName = getStatTypeLabel(position.statType); @@ -1288,9 +1377,10 @@ const Grid = ({ )} selectedElements={selectedElements} onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, false, false) + moveSelectedElements(deltaX, deltaY, undefined, false) } - onMultiDragEnd={syncSelectedElementsToOverlay} + onMultiDragStart={beginSelectedPluginInstancesDrag} + onMultiDragEnd={commitSelectedElementsDrag} activeTool={activeTool} onEraserClick={() => { showConfirm( @@ -1595,9 +1685,10 @@ const Grid = ({ return true; }} onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, false, false) + moveSelectedElements(deltaX, deltaY, undefined, false) } - onMultiDragEnd={syncSelectedElementsToOverlay} + onMultiDragStart={beginSelectedPluginInstancesDrag} + onMultiDragEnd={commitSelectedElementsDrag} /> {/* 스마트 가이드 오버레이 */} diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index cb06fba4..fadcb8b7 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; import { useSettingsStore } from '@stores/useSettingsStore'; import { @@ -244,6 +244,11 @@ const GroupResizeHandles = ({ maxShrinkX: 0, maxShrinkY: 0, }); + const activeResizeCleanupRef = useRef<(() => void) | null>(null); + + useLayoutEffect(() => { + return () => activeResizeCleanupRef.current?.(); + }, []); // 그룹 바운딩 박스 계산 const groupData = calculateGroupBounds( @@ -346,7 +351,8 @@ const GroupResizeHandles = ({ handle, }; - onGroupResizeStart?.(handle); + let resizeStarted = false; + let resizeFinished = false; const handleMouseMove = (moveEvent: MouseEvent) => { if (!resizeRef.current.isResizing) return; @@ -702,7 +708,7 @@ const GroupResizeHandles = ({ finalGroupMaxY = Math.max(finalGroupMaxY, bounds.y + bounds.height); } - onGroupResize?.({ + const result = { groupBounds: { x: finalGroupMinX, y: finalGroupMinY, @@ -711,23 +717,49 @@ const GroupResizeHandles = ({ }, elementBounds: newElementBounds, handle, + }; + const changed = newElementBounds.some(({ element, bounds }) => { + const initial = startElementBounds.find( + (entry) => + entry.element.id === element.id && + entry.element.type === element.type && + entry.element.index === element.index, + ); + return ( + !initial || + bounds.x !== initial.bounds.x || + bounds.y !== initial.bounds.y || + bounds.width !== initial.bounds.width || + bounds.height !== initial.bounds.height + ); }); + if (!resizeStarted && changed) { + resizeStarted = true; + onGroupResizeStart?.(handle); + } + if (resizeStarted) onGroupResize?.(result); }; const handleMouseUp = () => { + if (resizeFinished) return; + resizeFinished = true; resizeRef.current.isResizing = false; + activeResizeCleanupRef.current = null; // 스마트 가이드 클리어 useSmartGuidesStore.getState().clearGuides(); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('blur', handleMouseUp); + window.removeEventListener('pointercancel', handleMouseUp); unlockCustomCursor(); - onGroupResizeEnd?.(); + if (resizeStarted) onGroupResizeEnd?.(); }; + activeResizeCleanupRef.current = handleMouseUp; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); window.addEventListener('blur', handleMouseUp); + window.addEventListener('pointercancel', handleMouseUp); }; if (!groupData || selectedElements.length < 2) return null; diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.tsx index 83b01b47..fe8ef63c 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { useSettingsStore } from '@stores/useSettingsStore'; import { CursorType, @@ -224,6 +224,11 @@ const ResizeHandles = ({ startBounds: null, startAspectRatio: 1, }); + const activeResizeCleanupRef = useRef<(() => void) | null>(null); + + useLayoutEffect(() => { + return () => activeResizeCleanupRef.current?.(); + }, []); const handleMouseDown = (e: React.MouseEvent, handle: HandleDef) => { e.preventDefault(); @@ -248,7 +253,8 @@ const ResizeHandles = ({ startAspectRatio, }; - onResizeStart?.(handle); + let resizeStarted = false; + let resizeFinished = false; const handleMouseMove = (moveEvent: MouseEvent) => { if (!resizeRef.current.isResizing) return; @@ -359,27 +365,43 @@ const ResizeHandles = ({ } // dy === 1: newY = startBounds.y (상단 앵커 유지) - onResize?.({ + const result = { x: newX, y: newY, width: newWidth, height: newHeight, handle: handle!, - }); + }; + const changed = + result.x !== startBounds!.x || + result.y !== startBounds!.y || + result.width !== startBounds!.width || + result.height !== startBounds!.height; + if (!resizeStarted && changed) { + resizeStarted = true; + onResizeStart?.(handle!); + } + if (resizeStarted) onResize?.(result); }; const handleMouseUp = () => { + if (resizeFinished) return; + resizeFinished = true; resizeRef.current.isResizing = false; + activeResizeCleanupRef.current = null; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('blur', handleMouseUp); + window.removeEventListener('pointercancel', handleMouseUp); unlockCustomCursor(); - onResizeEnd?.(); + if (resizeStarted) onResizeEnd?.(); }; + activeResizeCleanupRef.current = handleMouseUp; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); window.addEventListener('blur', handleMouseUp); + window.addEventListener('pointercancel', handleMouseUp); }; if (!bounds) return null; diff --git a/src/renderer/components/main/Grid/layers/GraphItem.tsx b/src/renderer/components/main/Grid/layers/GraphItem.tsx index 429453c9..1256fdd2 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.tsx @@ -52,7 +52,7 @@ interface GraphItemProps { isSelected?: boolean; selectedElements?: SelectedElement[]; onMultiDrag?: (dx: number, dy: number) => void; - onMultiDragStart?: () => void; + onMultiDragStart?: () => void | (() => void); onMultiDragEnd?: () => void; activeTool?: string; onEraserClick?: () => void; diff --git a/src/renderer/components/main/Grid/layers/KnobItem.tsx b/src/renderer/components/main/Grid/layers/KnobItem.tsx index a32832c5..c8d8ba23 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.tsx @@ -75,7 +75,7 @@ interface KnobItemProps { isSelected?: boolean; selectedElements?: SelectedElement[]; onMultiDrag?: (dx: number, dy: number) => void; - onMultiDragStart?: () => void; + onMultiDragStart?: () => void | (() => void); onMultiDragEnd?: () => void; activeTool?: string; onEraserClick?: () => void; diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 9e385355..962259e2 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -50,21 +50,21 @@ import { DEFAULT_OBS_PORT } from '@src/types/obs'; // 설정 미리보기 영상 const PREVIEW_SOURCES: Record = { overlayLock: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/overlay-lock.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/overlay-lock.webm', alwaysOnTop: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/alwaysontop.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/alwaysontop.webm', noteEffect: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/noteeffect.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/noteeffect.webm', keyCounter: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/counter.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/counter.webm', customCSS: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/css.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/css.webm', customJS: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/plugin.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/plugin.webm', resizeAnchor: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/resize.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/resize.webm', obsMode: - 'https://raw.githubusercontent.com/lee-sihun/DmNote/master/docs/assets/webm/obs.webm', + 'https://raw.githubusercontent.com/DmNote-App/DmNote/master/docs/assets/webm/obs.webm', }; // ASIO 버퍼 크기 선택지(프레임). 게임 설정값과 맞춰야 ASIO 공존 가능. diff --git a/src/renderer/components/main/Tool/ToolBar.tsx b/src/renderer/components/main/Tool/ToolBar.tsx index fd02ab24..1be0370b 100644 --- a/src/renderer/components/main/Tool/ToolBar.tsx +++ b/src/renderer/components/main/Tool/ToolBar.tsx @@ -56,7 +56,7 @@ const ToolBar = ({